Skip to content

fix(runtime): keep interactive PTY control responsive under output floods - #5406

Merged
Astro-Han merged 3 commits into
mainfrom
fix/terminal-pty-write-path
Sep 18, 2026
Merged

Astro-Han merged 3 commits into
mainfrom
fix/terminal-pty-write-path

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Opening or typing in a Maka terminal stalled for seconds whenever the PTY was producing output. Measured in real Electron with a real PTY: write() p95 = 5.6s during a seq 1 300000 flood.

Root cause: PtyScreenCollector.accept chained one paced parser task per node-pty event (~15B each), so a keystroke's control cut queued behind tens of thousands of pending parses — 5.9s for a single mutateAndSnapshotAtCut. The queue was bounded by bytes (1MB) but not by task count. On top of that, every client keystroke paid a full screen snapshot, a synchronous shell-run row rewrite, and a ~44KB shell-runs:update fanout, none of which any client-side caller consumed.

Changes, all inside the existing authorities:

  • PtyScreenCollector.accept merges unstarted admissions into the pending tail. Queue depth goes from O(events) to one in-flight write plus one merged tail; byte budget, eviction, generation tracking, and cut ordering are unchanged. Flood benchmark: writeStdin during seq 1 300000 drops from ~5.8s to 0–8ms.
  • writeStdin splits by caller. client (Desktop keystrokes) runs the ordered mutation only — no synchronous snapshot or persist, since the reply carries no output — while model keeps mutateAndSnapshotAtCut because its tool result feeds the transcript. A client resize still schedules persistence because it changes the screen without producing output.
  • Runtime Resource wire replies match their consumers: control and stop replies drop the unused resource snapshot, start replies accept compact state, and updates for Desktop-owned terminals project compact state. Desktop terminals are identified by the desktop-terminal- launch prefix, moved to @maka/core with a shared isDesktopTerminalShellRun predicate.
  • runtime.resource.controller.acquire checks the live PTY handle first instead of forcing a live snapshot persist on every terminal attach; when there is no live handle it still reads through the manager so a stale active record is repaired to orphaned, and a PTY that has already exited is no longer acquirable.
  • PTY data sequence now advances once per published event, and getLivePtySnapshot flushes pending bytes first so a snapshot always sits on a publish boundary. Previously the sequence advanced per 4K chunk while each publish carried only the last value, so every multi-chunk publish (any output flood) looked like a gap to the renderer and forced a snapshot resync. Pre-existing since feat(desktop): add Codex-style side conversations #2428; fixed here because it sits on the same seam and undermines the flood behaviour this PR targets.
  • Desktop main shell-runs:start synthesizes the ShellRunUpdate from the start reply instead of a second getRuntimeResource round trip. Preload's runtime-host:identities pull becomes pull-on-miss since the push channel already maintains the maps; previously it fired once per keystroke.
  • SessionTerminalRenderQueue coalesces adjacent pending writes (same defect on the renderer side), and the PTY raw replay buffer trims amortized instead of copying 16KB per event.
  • coordinator.inspectResource is removed from the interface (no remaining caller); the manager-level method stays for session recovery.

Breaking change

Runtime Resource protocol shape changed; RUNTIME_HOST_COMPATIBILITY_EPOCH bumps 160 → 161 so mismatched Client/Host pairs are rejected at handshake. CLI MakaUserCommand.result widens to ShellRunStateResult; after a successful runtime.resource.stop the driver retires the command locally and publishes the terminal state through the existing resource-query path.

Review focus

Three independent adversarial reviews (SWE-2) ran against the first commit, split by authority: Runtime collector/manager, Host protocol/coordinator plus CLI consumers, and Desktop main/preload/renderer. The second commit fixes what they found: the unbounded start reply (a one-shot command finishing inside the launch with ~50KB per stream overflowed the wire limit and drained the Host), the lost orphan repair in acquire, and the PTY sequence contract above. Two accepted relaxations remain and are worth a look:

  • A queued cut is no longer a strict admission fence: bytes admitted after a mutateAtCut was queued can merge into the pre-cut tail and be parsed before the mutation runs. Everything admitted before the cut is still parsed before it, and no consumer in the tree depends on the stronger property (the mutation reads current size/mode, and snapshot consumers only need "at least through the cut").
  • Preload's runtimeHostSessionRef trusts a cache hit. During the gap between a Host generation flip and delivery of runtime-host-profiles:changed, a keystroke can be routed to the dead targetEpoch and rejected by the scoped handler; the old per-call identities pull narrowed but did not close the same window, and the maps converge on push delivery.

Verification

  • @maka/runtime build plus shell-run-manager and pty-screen-collector tests: 67 pass, 4 skipped, 0 fail, including a new coalescing test, a per-publish sequence test, and a reworked deterministic exit-before-cut test.
  • @maka/runtime-host build plus resource coordinator, process, protocol, two-client UDS, and session continuity tests: 82 pass, including new tests for the bounded start reply and the acquire repair path.
  • maka-agent (CLI) build plus session-driver and transcript tests: 74 + 113 pass.
  • @maka/desktop build plus terminal hydration, observer, domains IPC, and workbar tests: 71 pass.
  • npm run format and npm run lint: clean.
  • Benchmark (real PTY, in-process): writeStdin under a seq 1 300000 flood goes from ~5.8s to 0–8ms; the pending parser queue stays at 2 entries across ~114k events.

AI use

  • Generative tooling made a substantive contribution

Tool(s) and scope: Devin — diagnosis, benchmarks, implementation, tests.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above

@github-actions github-actions Bot added the effort/L Under 1000 readable lines label Sep 16, 2026
@Astro-Han
Astro-Han force-pushed the fix/terminal-pty-write-path branch from 466f0ed to 7171635 Compare September 16, 2026 16:59

@jackwener jackwener left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent agent review. Reviewed at 7171635ee99759b38bb380d4541983f800790e9d. I am an AI agent (executing seat @kabi-opus) publishing through a shared GitHub account; this is an automated review and does not substitute for independent human review.

No P0–P2 findings. One sequencing collision worth deciding deliberately, below.

The epoch is already taken by another open PR

RUNTIME_HOST_COMPATIBILITY_EPOCH is 161 here, and main is at 160 — but #5366 also sets 161. Both are open, and both carry incompatible protocol changes.

I checked whether that can silently produce two mutually-incompatible builds that both advertise 161, which would defeat the handshake the epoch exists for. It cannot: scripts/protocol-epoch-check.mjs rejects headEpoch === baseEpoch when incompatible protocol files changed, and separately rejects any backward move. So whichever merges second fails that check and is forced to 162.

That makes this a sequencing question rather than a defect — but it is one worth deciding rather than discovering in CI, because the loser rebases a 23-file or 129-file branch. Raising it here only because both are open at once and I reviewed the other one an hour ago.

The client/model split is justified by what the reply carries

writeStdin now branches on input.caller, and the reasoning holds where it matters: a client control reply carries no output, so the ordered mutation is all it needs, while model keeps mutateAndSnapshotAtCut because its tool result feeds the transcript. The code says why, and it names the exception rather than leaving it implicit — a client resize still persists eagerly, because it changes the screen without producing output that would otherwise refresh the record.

The stop path still takes the cut (mutateAtCut(() => this.beginStopTermination(...))), so ordering for termination is unchanged by the split.

The relaxation you flagged is bounded by its consumers

You call out that a queued cut is no longer a strict admission fence — bytes admitted after mutateAtCut was queued can merge into the pre-cut tail and be parsed before the mutation runs. The two consumers are the control mutation, which reads current size/mode, and stop termination, which signals the process; neither depends on the screen being exactly at the cut, and everything admitted before the cut is still parsed before it. Flagging it yourself rather than letting a reviewer find it is what made it cheap to check.

Evidence

Built core, storage, runtime, runtime-host, ui and CLI from a cleaned dist: zero TypeScript errors. pty-screen-collector + shell-run*: 77 pass, 0 fail. runtime-resource-coordinator: 27/27. CLI suite: 1062 pass, 0 fail, 3 skipped. Desktop main: 2697/2697.

A note for re-verification, since I hit it again on this PR: @maka/desktop's typecheck consumes packages/ui/dist, so a dist left from another branch surfaces as errors in files this PR never touches — here, VirtualizerHandle and attachCommitScheduler from #5366, producing 38 failures that vanish once @maka/ui is rebuilt at this head. Build order matters more than usual while several large branches are in flight.

Not covered by me

Every performance number in this PR. write() p95 5.6s → 0–8ms during a seq 1 300000 flood is a real-Electron, real-PTY measurement; I cannot launch Electron, so that figure and the mutateAndSnapshotAtCut 5.9s baseline are your measurements, not something this review confirms. The same applies to the renderer-side coalescing and the replay-buffer trim — I read them, I did not observe them under load.

The three adversarial reviews and their findings are likewise reported rather than reproduced here; what I verified is that the fixes they prompted are present in the diff, not that the original defects were as described.

This PR is currently a draft.

Code review, CI status and merge readiness are separate. This approval covers code only and is not a statement that the PR may be merged.

@Astro-Han
Astro-Han marked this pull request as ready for review September 17, 2026 16:57
…oods

A node-pty output flood queued one paced parser task per event (~15B each),
so a keystroke's control cut waited behind tens of thousands of queued
parses — measured 5.8s during `seq 1 300000`. Client writes also paid a
full screen snapshot, a shell-run row rewrite, and a ~44KB update fanout
per key, none of which any caller consumed.

PtyScreenCollector.accept now merges unstarted admissions into the pending
tail, bounding queue depth to one in-flight write plus one merged tail
instead of one task per event; byte budget, eviction, generation, and cut
ordering are unchanged. Client-originated writeStdin mutates at the cut
without the synchronous snapshot + persist — its reply carries no output —
while model-originated writes keep the snapshot contract their tool result
feeds. A client resize still schedules persistence since it changes the
screen without producing output.

Runtime Resource replies are slimmed to match their consumers: control and
stop replies drop the unused resource snapshot, start replies accept
compact state, and Desktop-owned terminal updates project compact state —
identified by the desktop-terminal launch prefix, now in @maka/core — since
no transcript tool call consumes their output. Compatibility epoch bumps
to 159; mismatched peers are rejected at handshake.

The renderer's SessionTerminalRenderQueue coalesces adjacent pending
writes the same way, and the PTY replay buffer trims amortized instead of
copying 16KB per event.

Generated-by: Devin
…ce PTY per publish

Adversarial review of the previous commit found three defects.

The runtime.resource.start reply lost its wire bound: a one-shot command
that reaches terminal status inside runBackgroundBash carries up to ~50KB
per stream, which overflowed RUNTIME_RESOURCE_RESULT_MAX_BYTES, failed the
operation, and drained the Host. The reply is bounded again through the
same shrinkStateToFit path the query pages use.

The durable-status read in acquire dropped the repair inspectResource
performed for an active record with no live handle, so a stale running PTY
record could never be marked orphaned by an attach and kept blocking
retirement. Acquire now checks the live handle first and, only when there
is none, reads through the manager so the record is repaired; a dying PTY
(driverExit/finalizeOnce) is no longer acquirable.

Pre-existing: rawSequence advanced once per 4K chunk while each published
event carried only the last value, so any publish spanning more than one
chunk looked like a gap to the renderer and forced a snapshot resync on
every output flood. The sequence now advances once per published event,
and getLivePtySnapshot flushes pending bytes first so a snapshot always
sits on a publish boundary.

Test fixtures still returning the removed control/stop resource shapes
were updated to the current wire contract.

Generated-by: Devin
@Astro-Han
Astro-Han force-pushed the fix/terminal-pty-write-path branch from 7171635 to 50b2f3a Compare September 17, 2026 17:06
getLivePtySnapshot calls collector.currentSize() on any live PTY whose exit has not landed yet. A collector that already failed (integrity failure, startup cleanup disposal) throws there, and acquire maps the throw to a Host-wide drain — one dying PTY plus a terminal attach would terminate every session's runs. Report the resource as gone instead, so acquire falls through to the stale-record repair path and answers the existing operation_conflict.

Generated-by: Devin
@Astro-Han
Astro-Han merged commit f02ac94 into main Sep 18, 2026
1 check passed
@Astro-Han
Astro-Han deleted the fix/terminal-pty-write-path branch September 18, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/L Under 1000 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants