mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Batch passive terminal output to animation-frame cadence
Replace the byte-size-based latency classifier with evidence-based classification (recent input or a reply-eliciting terminal query), and gate passive foreground drains behind a shared requestAnimationFrame (with a 32ms fallback) instead of a zero-delay drain. This coalesces bursts of tiny passive chunks into one xterm write per frame, cutting renderer wakeups for streaming agent output while preserving immediate echo/redraw and protocol-reply latency. Adds the design doc and test coverage for frame batching, ordered flush-behind-prefix, and fallback draining.
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
# Terminal Output Energy Optimization
|
||||
|
||||
Date: 2026-07-13
|
||||
|
||||
Status: approved for implementation by the analysis below
|
||||
|
||||
## Problem and evidence
|
||||
|
||||
Visible terminal output converges in
|
||||
`src/renderer/src/components/terminal-pane/pty-connection.ts`. Native, WSL, and SSH terminals
|
||||
arrive through `createIpcPtyTransport`; remote-runtime terminals arrive through
|
||||
`createRemoteRuntimePtyTransport`. Both transports invoke the same `dataCallback`, which preserves
|
||||
renderer sequence handling, hidden-output recovery, protocol-query extraction, and then calls
|
||||
`writePtyOutputToXterm`.
|
||||
|
||||
`writePtyOutputToXterm` currently classifies foreground output with
|
||||
`isLatencySensitiveForegroundOutput` before calling
|
||||
`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts`:
|
||||
|
||||
- Every active-pane chunk at or below 2 KiB is classified as latency-sensitive, independent of
|
||||
whether the user recently typed or the bytes contain a terminal query.
|
||||
- Larger ANSI redraws are also latency-sensitive for 150 ms after accepted terminal input.
|
||||
- A rolling budget permits up to 128 KiB of immediate foreground writes in 500 ms. Inactive split
|
||||
panes have a separate 32 KiB immediate budget.
|
||||
- Once the budget is exhausted, `latencySensitive: false` queues output, but the scheduler arms a
|
||||
zero-delay high-priority drain. A continuing series of separately delivered tiny chunks can
|
||||
therefore cause one renderer task and one xterm write per chunk instead of one write per visual
|
||||
frame.
|
||||
|
||||
The existing scheduler already supplies the safety mechanisms that a new cadence must retain:
|
||||
|
||||
- Per-terminal queues preserve byte order and coalesce adjacent chunks without changing their
|
||||
bytes.
|
||||
- Parse-deferred PTY ACK credits are attached to chunks and fire exactly once after the final xterm
|
||||
slice parses, or on every discard/drop path. Main-process producer flow control therefore follows
|
||||
actual renderer parse progress.
|
||||
- Foreground queues are capped, sliced into 16 KiB writes, drained cooperatively with an 8 ms task
|
||||
budget, and parse-clocked during large visible backlogs.
|
||||
- Background/hidden terminals use a shared delayed drain and bounded backlog. Snapshot-backed
|
||||
hidden panes may park delivery in main and restore from authoritative terminal state.
|
||||
- Native Windows synchronized output has separate hold/coalesce state, cursor-restore detection,
|
||||
and latency-sensitive fallbacks for frames opened near accepted input.
|
||||
- Visibility resume, shutdown capture, scroll handling, and terminal disposal call the scheduler's
|
||||
flush/discard APIs rather than writing around its queue.
|
||||
|
||||
The main-process runtime RPC also batches output for remote subscribers, but that does not solve
|
||||
this renderer issue: the PTY delivery boundaries seen by the active desktop renderer can still be
|
||||
small and repeated, and the current <=2 KiB rule turns each one into immediate xterm activity.
|
||||
|
||||
## Goals
|
||||
|
||||
- Preserve immediate echo/redraw behavior when there is evidence of accepted user input.
|
||||
- Preserve immediate parsing of reply-eliciting terminal protocol queries so DA, DSR/CPR, DECRQM,
|
||||
OSC color, and related replies meet programs' raw-mode read windows.
|
||||
- Coalesce passive visible output to at most one initial scheduler drain per animation frame under
|
||||
normal visible-window operation.
|
||||
- Keep continuous foreground output visually smooth at the display's animation-frame cadence; do
|
||||
not impose a fixed 30 fps throttle or limit an established backlog to one slice per frame.
|
||||
- Materially reduce renderer tasks, xterm writes, repaints, and CPU wakeups for streaming agents
|
||||
that emit many tiny chunks, with or without GPU acceleration.
|
||||
- Preserve exact bytes, ordering, parse-deferred ACK accounting, bounded memory, visibility
|
||||
transitions, synchronized output, and large-backlog throughput.
|
||||
- Apply the same policy to local, SSH, WSL, and remote-runtime output without host-specific timing
|
||||
assumptions.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not disable or change `caffeinate`, agents, terminal processes, or shell behavior.
|
||||
- Do not change GPU settings or assume WebGL is enabled.
|
||||
- Do not alter main-process PTY batching, terminal stream protocol framing, mobile subscriptions, or
|
||||
Git polling.
|
||||
- Do not change hidden-terminal parking, snapshot restoration, backlog loss policy, or terminal
|
||||
protocol reply contents.
|
||||
- Do not introduce a user setting, UI, telemetry upload, or platform-specific cadence.
|
||||
- Do not optimize xterm parsing or renderer internals beyond reducing how often Orca feeds passive
|
||||
chunks into them.
|
||||
- Do not change agent/worktree status animation behavior in this rollout. Status animations are a
|
||||
separate renderer-energy follow-up documented below so their cost is measured independently from
|
||||
terminal writes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Keep the 2 KiB rule and lower its byte budget
|
||||
|
||||
This reduces the longest immediate burst but preserves the incorrect signal: passive output remains
|
||||
"interactive" solely because transport framing happened to be small. It also still produces many
|
||||
immediate writes below the new budget.
|
||||
|
||||
### Add a fixed debounce such as 8-16 ms
|
||||
|
||||
A timer can coalesce chunks, but its wakeup is not aligned with the renderer's paint opportunity and
|
||||
can add a second task immediately before or after a frame. Chromium timer clamping also varies when
|
||||
the app is backgrounded. The repository already distinguishes visible from hidden delivery, so an
|
||||
animation-frame boundary is the more direct visible-output signal.
|
||||
|
||||
### Cap passive output at approximately 30 fps or add battery/idle modes
|
||||
|
||||
A 30 fps cap could reduce wakeups further, but it requires a second cadence source, a reliable
|
||||
definition of idle/battery state across macOS, Linux, and Windows, and policy for active prompts and
|
||||
watched split panes. There is no measurement yet showing that once-per-display-frame batching is
|
||||
insufficient. Starting at one animation frame is less risky and still collapses potentially many PTY
|
||||
deliveries into one xterm write. A 30 fps policy should be considered only after the measurement plan
|
||||
below shows a remaining material cost.
|
||||
|
||||
### Batch all foreground output, including input-driven and protocol traffic
|
||||
|
||||
This is simple but adds avoidable latency to typing and can make terminal protocol clients time out.
|
||||
The existing input timestamp and shared renderer-query grammar provide stronger evidence than byte
|
||||
length and allow the fast path to remain narrow.
|
||||
|
||||
### Change main-process delivery batching instead
|
||||
|
||||
Main batching affects IPC frequency but cannot safely infer renderer visibility, active-pane input,
|
||||
or xterm reply ownership, and remote-runtime output has a different transport. Renderer scheduling is
|
||||
the common point where all execution paths and paint cadence are known.
|
||||
|
||||
### Animate only the selected foreground agent
|
||||
|
||||
This could reduce the number of animated indicators, but it breaks an important status affordance:
|
||||
users must be able to see that agents in other worktrees, tabs, and split panes are still working.
|
||||
Selection is also not literal keyboard focus; Orca's selected terminal pane remains selected while
|
||||
the user interacts with the sidebar or another window. Any status-animation optimization must be
|
||||
based on actual visibility, not active-worktree or active-pane identity.
|
||||
|
||||
## Chosen design
|
||||
|
||||
### Evidence-based latency classification
|
||||
|
||||
Remove the rule that makes every active-pane chunk <=2 KiB latency-sensitive. Foreground output is
|
||||
immediate only when one of these facts is present:
|
||||
|
||||
1. The bytes complete a reply-eliciting terminal query recognized by the existing shared query
|
||||
grammar. A small per-connection scan tail carries split CSI/OSC queries across PTY chunks.
|
||||
2. The active split pane accepted terminal input within the existing 150 ms interactive redraw
|
||||
window, subject to the existing 128 KiB/500 ms safety budget.
|
||||
3. A native Windows synchronized-output frame was opened within its existing interactive window;
|
||||
the current frame latch and 16/32 ms cursor-protection fallbacks remain unchanged.
|
||||
|
||||
Protocol-query evidence bypasses the byte budget because withholding a reply can block the program
|
||||
that issued the query. Inactive visible split panes do not get an input fast path, but protocol
|
||||
queries remain immediate. Output without one of these facts is passive even when it is one byte.
|
||||
Prompts and ordinary command completion can wait until the next frame (normally <= one refresh
|
||||
interval); direct keyboard echo and the redraw it triggers remain immediate.
|
||||
|
||||
### Shared animation-frame gate
|
||||
|
||||
`latencySensitive: false` foreground output remains in the existing per-terminal scheduler queue,
|
||||
but its first drain waits for one shared `requestAnimationFrame`. All passive chunks and terminals
|
||||
arriving before that callback share the frame. The frame callback releases those entries and invokes
|
||||
the existing high-priority drain, so slicing, time budgets, parse pacing, refresh handling, and queue
|
||||
caps are unchanged after the initial gate.
|
||||
|
||||
This aligns new passive output with the next available paint rather than introducing a coarse timer.
|
||||
On a continuously rendering 60 Hz display the normal initial wait is at most one approximately
|
||||
16.7 ms frame, and higher-refresh displays naturally get shorter waits. Once a backlog is released,
|
||||
the existing cooperative continuation keeps making progress between renderer tasks; it is not
|
||||
restricted to one chunk or one 16 KiB slice per animation frame. The design therefore removes
|
||||
redundant within-frame writes without intentionally skipping frames or making a streaming terminal
|
||||
advance at a fixed low cadence.
|
||||
|
||||
A one-shot 32 ms fallback is armed with the frame request. It exists only to bound starvation when
|
||||
`requestAnimationFrame` is unavailable or suspended despite the pane being treated as foreground
|
||||
(including stale Electron visibility recovery). The frame callback cancels the fallback and the
|
||||
fallback cancels the frame. This is not a polling loop and does not enforce 30 fps during normal
|
||||
visible rendering.
|
||||
|
||||
If latency-sensitive bytes arrive behind passive bytes waiting for a frame, the scheduler cancels
|
||||
that terminal's frame wait and synchronously flushes the ordered queue before the new query/echo can
|
||||
be parsed. Explicit flush, visibility resume, shutdown capture, and discard likewise release or
|
||||
cancel frame-wait state so no callback can strand or replay an entry.
|
||||
|
||||
## Invariants
|
||||
|
||||
### Ordering and exact semantics
|
||||
|
||||
- Each terminal has one ordered queue. Coalescing concatenates bytes in arrival order without
|
||||
decoding, rewriting, dropping, or inserting bytes.
|
||||
- Immediate traffic never overtakes earlier passive traffic. It first releases and flushes the
|
||||
earlier frame-waiting queue.
|
||||
- Existing `beforeWrite`, `onParsed`, refresh, cursor-show stripping, and scroll-intent boundaries
|
||||
remain associated with the same ordered chunks.
|
||||
- Scheduler flush/discard and hidden restore continue to be the only exceptional delivery paths.
|
||||
|
||||
### ACK credits and backpressure
|
||||
|
||||
- Every delivered PTY chunk contributes at most one fire-once ACK credit.
|
||||
- Coalescing may attach multiple credits to one xterm write; all fire only after the corresponding
|
||||
bytes' final slices parse.
|
||||
- Drop, overflow, disposal, write failure, and snapshot-recovery paths continue to fire credits for
|
||||
consumed deliveries exactly once.
|
||||
- The frame wait delays credits by at most one visible frame or the 32 ms fallback. Main may briefly
|
||||
reach its in-flight cap and pause the PTY; the frame drain parses bytes, returns credits, and resumes
|
||||
production. No credit depends on a future chunk, so an idle producer cannot deadlock the queue.
|
||||
|
||||
### Latency and starvation
|
||||
|
||||
- Accepted active-pane input and complete reply-eliciting terminal queries take the immediate path,
|
||||
except that prior bytes must be parsed first to preserve ordering.
|
||||
- Passive visible output starts draining on the next animation frame under normal operation.
|
||||
- Continuous passive output may merge deliveries within a frame, but is never deliberately held to
|
||||
alternate frames or a 30 fps cadence.
|
||||
- If no frame runs, the one-shot fallback releases it within 32 ms.
|
||||
- Once a large passive backlog starts, the current high-priority parse-clocked drain continues; it is
|
||||
not limited to 16 KiB per frame and retains the existing 8 ms cooperative task budget.
|
||||
- Hidden/background output retains its existing 50 ms shared delay and 16 ms continuation cadence.
|
||||
|
||||
### Synchronized output
|
||||
|
||||
- DEC 2026 hold/coalesce, cursor restoration, native Windows-only protection, and interactive frame
|
||||
latching are unchanged.
|
||||
- Frame gating applies only when the existing synchronized-output state machine has released an entry
|
||||
as ordinary non-latency-sensitive foreground output.
|
||||
|
||||
## SSH and cross-platform implications
|
||||
|
||||
- Native macOS/Linux/Windows, WSL, and SSH PTYs use the IPC transport but converge on the same
|
||||
renderer callback. Remote-runtime PTYs use a separate subscription transport and converge before
|
||||
classification. No host clock, path syntax, shell, or Git behavior changes.
|
||||
- Remote input debounce is not used for protocol replies today and remains bypassed. Query bytes are
|
||||
parsed immediately so the existing `sendDesktopQueryReplyImmediate` path can answer local and
|
||||
remote programs within their read windows.
|
||||
- Native Windows ConPTY retains its synchronized-output and renderer-refresh repairs. The cadence
|
||||
does not depend on WebGL, so DOM rendering/GPU-disabled configurations benefit from fewer writes
|
||||
too.
|
||||
- Hidden documents and hidden panes remain background delivery. The 32 ms one-shot fallback covers
|
||||
the exceptional stale-visibility case where Orca deliberately treats a pane as foreground even if
|
||||
Chromium may suppress animation frames.
|
||||
|
||||
## Related agent-status animation follow-up
|
||||
|
||||
This terminal-output change reduces xterm work but does not make the renderer fully idle if visible
|
||||
status indicators continue animating. The follow-up is documented here because it is a measurement
|
||||
confounder and may become the dominant avoidable activity while a working agent is quiet or waiting
|
||||
on a remote tool. It is not implemented as part of this rollout.
|
||||
|
||||
### Current behavior and evidence
|
||||
|
||||
- `StatusIndicator` renders the aggregate working state on a worktree as an infinite stepped CSS
|
||||
rotation: `spin 1s steps(12, end)`.
|
||||
- `AgentStateDot` uses the same 12-step rotation for working agent rows and terminal tabs. A visible
|
||||
worktree can therefore show both an aggregate working spinner and one or more agent-status
|
||||
spinners. Compact agent summaries group identities that share a state, so the adjacent provider
|
||||
icons are static and do not each represent another animation.
|
||||
- The worktree list is virtualized with ten overscan rows. Collapsing the sidebar unmounts the list,
|
||||
but overscan-only rows can remain mounted outside the clipped viewport.
|
||||
- Individual `AgentStateDot` spinners honor reduced-motion preferences. The aggregate
|
||||
`StatusIndicator` spinner does not currently have equivalent behavior.
|
||||
- A stepped animation changes visible orientation 12 times per second, but that alone does not prove
|
||||
Chromium schedules only 12 compositor wakeups. Separately mounted spinners can also start out of
|
||||
phase, spreading their visual transitions across more frame opportunities. The actual renderer and
|
||||
GPU-process cost must be measured, especially with GPU acceleration disabled.
|
||||
|
||||
### Visibility and status invariants
|
||||
|
||||
- Every actually visible `working` agent remains animated regardless of which worktree, tab, or pane
|
||||
is active. Background work must not look idle merely to save energy.
|
||||
- Aggregate worktree status remains live when visible; optimization must not replace visible
|
||||
background-agent motion with a selected-pane-only policy.
|
||||
- Indicators may pause only when they cannot be seen: the document is hidden, the sidebar is
|
||||
collapsed, or a virtualized row is outside the real viewport rather than merely mounted as
|
||||
overscan. They resume when visible again.
|
||||
- Non-working states remain static. A reduced-motion preference may intentionally override animation
|
||||
because it is an explicit accessibility choice.
|
||||
- Visibility changes must be event-driven. Do not add a polling interval or perpetual JavaScript
|
||||
animation-frame loop to manage spinner cadence.
|
||||
|
||||
### Least-risk follow-up design
|
||||
|
||||
1. Move the working-ring animation into one shared status-indicator primitive/class used by
|
||||
aggregate worktree status, individual agent rows, compact summaries, and terminal tabs.
|
||||
2. Phase-lock visible spinners to one shared stepped cadence, using the CSS/document timeline or a
|
||||
one-time phase offset rather than a recurring JavaScript clock. All visible working agents still
|
||||
move, but their steps occur together instead of creating staggered paint opportunities.
|
||||
3. Pause overscan-only worktree rows using the virtualizer's real visible range, while preserving the
|
||||
existing mounted overscan needed for smooth scrolling.
|
||||
4. Pause on document-hidden transitions and resume on visibility restoration. Sidebar collapse
|
||||
already unmounts its indicators and should keep doing so.
|
||||
5. Apply reduced-motion behavior consistently to both `AgentStateDot` and the aggregate
|
||||
`StatusIndicator`.
|
||||
6. Retain the current 12 visible steps per second initially. Compare 12 and 8 phase-locked steps in
|
||||
visual and energy measurements; adopt 8 only if it remains perceptually smooth and produces a
|
||||
material reduction. Lowering the CSS step count is not assumed to lower Chromium wakeups without
|
||||
profiler evidence.
|
||||
|
||||
Avoid `setInterval`, a throttled perpetual `requestAnimationFrame`, per-spinner timers, animated
|
||||
image substitutions, and unconditional `will-change` layer promotion. Those approaches can add
|
||||
main-thread wakeups, decoding, memory, or compositor layers without proving a net energy win.
|
||||
|
||||
## Test plan
|
||||
|
||||
Use deterministic fake timers and a manually controlled `requestAnimationFrame` queue.
|
||||
|
||||
Scheduler tests will prove:
|
||||
|
||||
- many passive tiny foreground chunks produce no write before the frame and one ordered coalesced
|
||||
write when it runs;
|
||||
- interactive traffic stays immediate, and traffic arriving behind a frame-waiting passive prefix
|
||||
preserves order while cancelling the stale frame;
|
||||
- multiple ACK credits coalesced into one write remain deferred until parse and fire exactly once;
|
||||
- the 32 ms fallback drains when no animation frame runs;
|
||||
- explicit flush/visibility transitions release frame-waiting output and stale frame callbacks do
|
||||
nothing;
|
||||
- existing overflow, discard, background, synchronized-output, fairness, and large-throughput tests
|
||||
continue to pass.
|
||||
|
||||
PTY connection tests will prove:
|
||||
|
||||
- passive <=2 KiB chunks are frame-batched instead of treated as interactive by size;
|
||||
- accepted keyboard input keeps echo/redraw output immediate;
|
||||
- complete and split renderer-query sequences are immediate without recent input, including in an
|
||||
inactive split pane;
|
||||
- native Windows synchronized-output fast and protected paths still meet their existing bounds;
|
||||
- local/SSH/WSL/remote-runtime transports continue to share the same host-neutral classification
|
||||
boundary, with existing transport suites retained for runtime-specific framing and input.
|
||||
|
||||
Run focused Vitest suites for the scheduler and PTY connection, then lint/typecheck and the
|
||||
repository max-lines ratchet in proportion to changed files. Run the existing scheduler e2e spec if
|
||||
the local Electron test environment is available; otherwise record that remaining validation.
|
||||
|
||||
The separate status-animation follow-up should add focused tests proving that visible working agents
|
||||
animate in active and inactive worktrees, overscan-only rows and hidden documents pause, visibility
|
||||
restoration resumes at the shared phase, collapsed-sidebar behavior remains unchanged, and
|
||||
reduced-motion disables both aggregate and per-agent animation. Tests should also prove that no
|
||||
recurring JavaScript timer or animation-frame loop is introduced.
|
||||
|
||||
## Measurement plan
|
||||
|
||||
Use a representative visible agent workload that emits timestamped 20-200 byte chunks continuously
|
||||
for at least five minutes, once with GPU acceleration enabled and once disabled. Keep `caffeinate`
|
||||
and the agent configuration unchanged. Repeat each baseline and candidate run at least three times
|
||||
after an idle settling period.
|
||||
|
||||
Enable the existing e2e terminal scheduler debug API and record:
|
||||
|
||||
- PTY deliveries/chunks generated by the workload;
|
||||
- `foregroundWriteCount`, `deferredForegroundEnqueueCount`,
|
||||
`deferredForegroundWriteCount`, `scheduledDrainCount`, and `drainWrites`;
|
||||
- xterm `write` calls and viewport refresh/render calls using a local diagnostic wrapper or the
|
||||
existing e2e instrumentation;
|
||||
- peak queued characters and any dropped-backlog count;
|
||||
- input-to-echo and query-to-reply latency samples, including p50/p95/max;
|
||||
- renderer CPU time, wakeups/idle wakeups, and energy impact from the platform profiler (macOS
|
||||
Instruments Energy Log/Time Profiler, Windows WPA, or Linux `perf`/PowerTOP as available).
|
||||
|
||||
Expected result: for passive tiny chunks, xterm writes and foreground drain activations approach the
|
||||
number of displayed frames rather than the number of PTY deliveries, with byte-for-byte output,
|
||||
zero additional drops, stable ACK progress, and no material regression in interactive latency.
|
||||
|
||||
Status animations must be controlled as a separate variable. Repeat the steady-state portion of the
|
||||
measurement with:
|
||||
|
||||
- one and many working agents, including agents in inactive but visible worktrees;
|
||||
- compact and full agent rows, with the sidebar open and collapsed;
|
||||
- the current status animations running and temporarily paused as a diagnostic baseline;
|
||||
- current staggered phases and the proposed shared phase;
|
||||
- overscan-only rows present, then explicitly paused; and
|
||||
- GPU acceleration enabled and disabled.
|
||||
|
||||
Measure both a streaming workload and a quiet `working` state where the agent is waiting on a remote
|
||||
tool and produces no terminal output. Record running animation counts, compositor/BeginFrame cadence,
|
||||
renderer and GPU-process CPU, raster/paint activity, idle wakeups, and platform energy impact. Multiple
|
||||
spinners may share compositor frames, so do not extrapolate linearly from element count. If pausing
|
||||
status animations does not change the quiet baseline, further spinner work is not justified; if it
|
||||
does, phase synchronization and invisible-row pausing should be evaluated before reducing visible
|
||||
cadence.
|
||||
|
||||
## Rollout risks and mitigations
|
||||
|
||||
- **Misclassified protocol query:** use the existing shared query grammar with a cross-chunk tail and
|
||||
targeted complete/split-query tests. Do not infer queries from generic escape bytes.
|
||||
- **ACK starvation:** retain parse-deferred credits and add coalesced-credit/fallback tests. The
|
||||
one-shot fallback provides an independent progress bound.
|
||||
- **Ordering regression when input follows passive output:** release and flush the existing ordered
|
||||
queue before parsing the immediate suffix; test both bytes and callbacks.
|
||||
- **Animation frames suppressed by Electron visibility state:** retain the current foreground/
|
||||
background visibility decision and add the 32 ms one-shot fallback.
|
||||
- **Throughput regression for large floods:** gate only the initial drain; keep current slicing,
|
||||
high-priority parse pacing, caps, and throughput benchmark.
|
||||
- **Windows TUI cursor regression:** leave synchronized-output and refresh policy unchanged and run
|
||||
its focused ConPTY tests.
|
||||
- **Extra frame callbacks after flush/dispose:** cancel the shared gate when no entry is waiting and
|
||||
make stale callbacks generation-safe.
|
||||
- **Misattributed residual wakeups:** visible working indicators can keep the compositor active after
|
||||
terminal writes are batched. Hold their state constant and run the status-animation measurement
|
||||
matrix before attributing total-process energy changes to this scheduler.
|
||||
- **Future status visibility regression:** a follow-up must never gate animation on active worktree or
|
||||
selected pane. Test inactive visible worktrees and offscreen-to-visible transitions explicitly.
|
||||
|
||||
## Rollback strategy
|
||||
|
||||
The change is renderer-only and does not alter persisted data, IPC schemas, snapshots, or PTY wire
|
||||
protocols. Rollback consists of removing the animation-frame wait state and restoring the prior
|
||||
`scheduleDrain(0)` call for `latencySensitive: false`, plus restoring the <=2 KiB classification
|
||||
branch. No migration or data repair is required. If classification alone is implicated, retain the
|
||||
frame gate and temporarily restore the small-chunk fast path; if scheduling is implicated, retain the
|
||||
evidence-based classifier and return passive output to the current zero-delay drain while collecting
|
||||
measurements.
|
||||
@@ -1760,7 +1760,7 @@ describe('connectPanePty', () => {
|
||||
capturedDataCallback.current?.('x'.repeat(16 * 1024))
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(0)
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('x'.repeat(16 * 1024), expect.any(Function))
|
||||
})
|
||||
|
||||
@@ -1818,6 +1818,117 @@ describe('connectPanePty', () => {
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function))
|
||||
})
|
||||
|
||||
it('batches passive tiny chunks into one foreground frame write', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('pty-1')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-1'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
await flushAsyncTicks()
|
||||
vi.useFakeTimers()
|
||||
|
||||
capturedDataCallback.current?.('passive ')
|
||||
capturedDataCallback.current?.('agent ')
|
||||
capturedDataCallback.current?.('output\r\n')
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
'passive agent output\r\n',
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('parses a complete terminal reply query immediately without recent input', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('pty-1')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-1'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
await flushAsyncTicks()
|
||||
capturedDataCallback.current?.('\x1b[6n')
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function))
|
||||
})
|
||||
|
||||
it('flushes an ordered split terminal query as soon as it completes', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('pty-1')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-1'
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
await flushAsyncTicks()
|
||||
vi.useFakeTimers()
|
||||
|
||||
capturedDataCallback.current?.('\x1b[')
|
||||
expect(pane.terminal.write).not.toHaveBeenCalled()
|
||||
capturedDataCallback.current?.('6n')
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[6n', expect.any(Function))
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(pane.terminal.write).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['ssh', 'remote-runtime'] as const)(
|
||||
'keeps terminal reply queries immediate for inactive %s panes',
|
||||
async (runtimeKind) => {
|
||||
if (runtimeKind === 'ssh') {
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
|
||||
sshConnectionStates: new Map([['conn-1', { status: 'connected' }]])
|
||||
} as StoreState
|
||||
} else {
|
||||
enableActiveRuntimeEnvironment()
|
||||
}
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
const transport = createMockTransport('pty-1')
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-1'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const manager = {
|
||||
...createManager(2),
|
||||
getActivePane: vi.fn(() => ({ id: 2 }))
|
||||
}
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
manager as never,
|
||||
createDeps({ isActiveRef: { current: false } }) as never
|
||||
)
|
||||
await flushAsyncTicks()
|
||||
capturedDataCallback.current?.('\x1b[c')
|
||||
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[c', expect.any(Function))
|
||||
}
|
||||
)
|
||||
|
||||
it('does not let OpenTUI-style small ANSI redraw bursts monopolize foreground writes', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const pane = createPane(1)
|
||||
@@ -1852,7 +1963,7 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
|
||||
expect(pane.terminal.write.mock.calls.length).toBeLessThan(frames.length)
|
||||
vi.advanceTimersByTime(0)
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(pane.terminal.write.mock.calls.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -8365,6 +8476,7 @@ describe('connectPanePty', () => {
|
||||
// must not re-arm restores — the post-gap bytes write through.
|
||||
dataCallback('', { droppedOutput: true })
|
||||
dataCallback('AFTER-FLOOD', { seq: 700 * 1024, rawLength: 11 })
|
||||
vi.advanceTimersByTime(32)
|
||||
await flushAsyncTicks(8)
|
||||
expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
|
||||
expect(writtenFloodData(pane)).toContain('AFTER-FLOOD')
|
||||
@@ -8933,7 +9045,7 @@ describe('connectPanePty', () => {
|
||||
capturedDataCallback.current?.(redraw)
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(redraw, expect.any(Function))
|
||||
vi.advanceTimersByTime(0)
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function))
|
||||
})
|
||||
|
||||
@@ -8966,7 +9078,7 @@ describe('connectPanePty', () => {
|
||||
capturedDataCallback.current?.(redraw)
|
||||
|
||||
expect(pane.terminal.write).not.toHaveBeenCalledWith(redraw, expect.any(Function))
|
||||
vi.advanceTimersByTime(0)
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function))
|
||||
})
|
||||
|
||||
@@ -10886,7 +10998,7 @@ describe('connectPanePty', () => {
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
vi.advanceTimersByTime(0)
|
||||
vi.advanceTimersByTime(32)
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
|
||||
@@ -10953,6 +11065,7 @@ describe('connectPanePty', () => {
|
||||
vi.advanceTimersByTime(0)
|
||||
await flushAsyncTicks(10)
|
||||
}
|
||||
vi.advanceTimersByTime(32)
|
||||
|
||||
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
|
||||
const warningIndex = written.findIndex((data) => data.includes('main recovery was unavailable'))
|
||||
@@ -11056,7 +11169,7 @@ describe('connectPanePty', () => {
|
||||
await flushAsyncTicks(4)
|
||||
|
||||
vi.advanceTimersByTime(750)
|
||||
vi.advanceTimersByTime(0)
|
||||
vi.advanceTimersByTime(32)
|
||||
await flushAsyncTicks(10)
|
||||
|
||||
const written = pane.terminal.write.mock.calls.map(([data]) => data as string)
|
||||
@@ -14324,6 +14437,7 @@ describe('connectPanePty', () => {
|
||||
bellHandler()
|
||||
idleHandler('* Codex done')
|
||||
vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS)
|
||||
vi.advanceTimersByTime(32)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.notifications.dispatch).toHaveBeenCalledTimes(1)
|
||||
@@ -16304,6 +16418,7 @@ describe('connectPanePty', () => {
|
||||
lastAssistantMessage: 'Done.'
|
||||
})
|
||||
vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS)
|
||||
vi.advanceTimersByTime(32)
|
||||
|
||||
expect(deps.dispatchNotification).not.toHaveBeenCalled()
|
||||
expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number))
|
||||
@@ -16572,6 +16687,7 @@ describe('connectPanePty', () => {
|
||||
agentType: 'claude',
|
||||
lastAssistantMessage: 'Done.'
|
||||
})
|
||||
vi.advanceTimersByTime(32)
|
||||
|
||||
expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, expect.any(Number))
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(
|
||||
|
||||
@@ -282,7 +282,6 @@ const TERMINAL_FOCUS_IN_SEQUENCE = '\x1b[I'
|
||||
const FOCUS_REPORTING_DISABLE_SEQUENCE = '\x1b[?1004l'
|
||||
const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250
|
||||
const SHIFT_ENTER_RECONFIRM_IDLE_MS = 350
|
||||
const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048
|
||||
const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 128 * 1024
|
||||
const FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS = 150
|
||||
// Why: a submit repaint can take longer than one keystroke echo to fully
|
||||
@@ -295,7 +294,6 @@ const FOREGROUND_SYNCHRONIZED_FRAME_INTERACTIVE_WINDOW_MS = 400
|
||||
// collectively starve timers unless foreground writes have a rolling budget.
|
||||
const FOREGROUND_IMMEDIATE_BUDGET_CHARS = 128 * 1024
|
||||
const FOREGROUND_BUDGET_WINDOW_MS = 500
|
||||
const INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS = 32 * 1024
|
||||
const FOREGROUND_GRID_DRIFT_CHECK_MIN_MS = 250
|
||||
// Why: this is only shown if hidden renderer output was skipped and main-owned
|
||||
// terminal state is unavailable, so the user has an explicit loss signal.
|
||||
@@ -590,8 +588,6 @@ let codexRestartNoticePresenceSource: Record<
|
||||
{ previousAccountLabel: string; nextAccountLabel: string }
|
||||
> | null = null
|
||||
let codexRestartNoticePresence = false
|
||||
let inactiveForegroundImmediateBudgetChars = 0
|
||||
let inactiveForegroundImmediateBudgetWindowStart = 0
|
||||
|
||||
type PanePtyBinding = IDisposable & {
|
||||
syncProcessTracking: () => void
|
||||
@@ -697,22 +693,6 @@ function isRemoteRuntimePtyId(ptyId: string | null | undefined): boolean {
|
||||
return typeof ptyId === 'string' && ptyId.startsWith(REMOTE_PTY_ID_PREFIX)
|
||||
}
|
||||
|
||||
function consumeInactiveForegroundImmediateBudget(dataLength: number): boolean {
|
||||
const now = performance.now()
|
||||
if (now - inactiveForegroundImmediateBudgetWindowStart > FOREGROUND_BUDGET_WINDOW_MS) {
|
||||
inactiveForegroundImmediateBudgetChars = 0
|
||||
inactiveForegroundImmediateBudgetWindowStart = now
|
||||
}
|
||||
if (
|
||||
inactiveForegroundImmediateBudgetChars + dataLength >
|
||||
INACTIVE_FOREGROUND_IMMEDIATE_BUDGET_CHARS
|
||||
) {
|
||||
return false
|
||||
}
|
||||
inactiveForegroundImmediateBudgetChars += dataLength
|
||||
return true
|
||||
}
|
||||
|
||||
function hasCodexRestartNotices(
|
||||
noticesByPtyId: Record<string, { previousAccountLabel: string; nextAccountLabel: string }>
|
||||
): boolean {
|
||||
@@ -4855,6 +4835,8 @@ export function connectPanePty(
|
||||
}
|
||||
let foregroundImmediateBudgetChars = 0
|
||||
let foregroundImmediateBudgetWindowStart = 0
|
||||
let foregroundRendererQueryPtyId: string | null = null
|
||||
let foregroundRendererQueryScanTail = ''
|
||||
let foregroundRewriteChunkEndedWithCarriageReturn = false
|
||||
let foregroundRewriteCsiScanTail = ''
|
||||
let hiddenMode2031ScanTail = ''
|
||||
@@ -5093,29 +5075,34 @@ export function connectPanePty(
|
||||
return activePane ? activePane.id === pane.id : true
|
||||
}
|
||||
|
||||
function isLatencySensitiveForegroundOutput(data: string): boolean {
|
||||
if (!isActiveSplitPane()) {
|
||||
// Why: many visible split panes can each emit tiny TUI frames. A shared
|
||||
// budget keeps watched panes live while preventing aggregate xterm work
|
||||
// from starving typing in the active pane.
|
||||
if (data.includes('\x1b[')) {
|
||||
return false
|
||||
}
|
||||
return consumeInactiveForegroundImmediateBudget(data.length)
|
||||
function containsForegroundRendererReplyQuery(data: string): boolean {
|
||||
const ptyId = transport.getPtyId()
|
||||
if (foregroundRendererQueryPtyId !== ptyId) {
|
||||
foregroundRendererQueryPtyId = ptyId
|
||||
foregroundRendererQueryScanTail = ''
|
||||
}
|
||||
if (data.length <= FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS) {
|
||||
return consumeForegroundImmediateBudget(data.length)
|
||||
const extracted = extractHiddenStartupRendererQueryData(data, foregroundRendererQueryScanTail)
|
||||
foregroundRendererQueryScanTail = extracted.pending
|
||||
return Boolean(
|
||||
extracted.statelessQueryData || extracted.statefulQueryData || extracted.oscColorQueryData
|
||||
)
|
||||
}
|
||||
|
||||
function isLatencySensitiveForegroundOutput(
|
||||
data: string,
|
||||
containsRendererReplyQuery: boolean
|
||||
): boolean {
|
||||
if (containsRendererReplyQuery) {
|
||||
return true
|
||||
}
|
||||
if (!isActiveSplitPane()) {
|
||||
return false
|
||||
}
|
||||
const recentInput =
|
||||
performance.now() - lastTerminalInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS
|
||||
if (
|
||||
recentInput &&
|
||||
data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS &&
|
||||
data.includes('\x1b[')
|
||||
) {
|
||||
return consumeForegroundImmediateBudget(data.length)
|
||||
}
|
||||
return false
|
||||
// Why: accepted input is evidence of echo/redraw work; transport chunk
|
||||
// size alone is not, so passive agent streams wait for the next frame.
|
||||
return recentInput && consumeForegroundImmediateBudget(data.length)
|
||||
}
|
||||
|
||||
function containsNonAsciiOutput(data: string): boolean {
|
||||
@@ -5306,6 +5293,8 @@ export function connectPanePty(
|
||||
const synchronizedFrameLatencySensitive =
|
||||
synchronizedForegroundOutput && synchronizedForegroundFrameInteractive
|
||||
synchronizedForegroundOutputActive = nextSynchronizedForegroundOutputActive
|
||||
const foregroundContainsRendererReplyQuery =
|
||||
foreground && containsForegroundRendererReplyQuery(data)
|
||||
if (!foreground && hiddenMode2031ScanTail) {
|
||||
respondToSkippedMode2031Subscribe(data)
|
||||
}
|
||||
@@ -5321,7 +5310,8 @@ export function connectPanePty(
|
||||
latencySensitive:
|
||||
!foreground || parseHiddenStartupOutput
|
||||
? true
|
||||
: synchronizedFrameLatencySensitive || isLatencySensitiveForegroundOutput(data),
|
||||
: synchronizedFrameLatencySensitive ||
|
||||
isLatencySensitiveForegroundOutput(data, foregroundContainsRendererReplyQuery),
|
||||
forceForegroundRefresh:
|
||||
foregroundOutput &&
|
||||
(synchronizedForegroundOutput ||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export const TERMINAL_OUTPUT_FRAME_FALLBACK_MS = 32
|
||||
|
||||
export type PaneTerminalOutputFrameGate = {
|
||||
schedule: () => void
|
||||
cancel: () => void
|
||||
isPending: () => boolean
|
||||
}
|
||||
|
||||
export function createPaneTerminalOutputFrameGate(
|
||||
onFrame: () => void
|
||||
): PaneTerminalOutputFrameGate {
|
||||
let frameId: number | null = null
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let generation = 0
|
||||
|
||||
const cancelHandles = (): void => {
|
||||
if (frameId !== null && typeof globalThis.cancelAnimationFrame === 'function') {
|
||||
globalThis.cancelAnimationFrame(frameId)
|
||||
}
|
||||
frameId = null
|
||||
if (fallbackTimer !== null) {
|
||||
clearTimeout(fallbackTimer)
|
||||
fallbackTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const release = (expectedGeneration: number): void => {
|
||||
if (expectedGeneration !== generation || (frameId === null && fallbackTimer === null)) {
|
||||
return
|
||||
}
|
||||
cancelHandles()
|
||||
onFrame()
|
||||
}
|
||||
|
||||
return {
|
||||
schedule: () => {
|
||||
if (frameId !== null || fallbackTimer !== null) {
|
||||
return
|
||||
}
|
||||
generation += 1
|
||||
const scheduledGeneration = generation
|
||||
fallbackTimer = setTimeout(
|
||||
() => release(scheduledGeneration),
|
||||
TERMINAL_OUTPUT_FRAME_FALLBACK_MS
|
||||
)
|
||||
if (typeof globalThis.requestAnimationFrame === 'function') {
|
||||
const requestedFrameId = globalThis.requestAnimationFrame(() =>
|
||||
release(scheduledGeneration)
|
||||
)
|
||||
// Test and embedded runtimes may invoke the callback synchronously.
|
||||
// Do not resurrect an already-released frame with its returned id.
|
||||
if (fallbackTimer !== null) {
|
||||
frameId = requestedFrameId
|
||||
}
|
||||
}
|
||||
// Why: Electron can suppress rAF after stale visibility state; one timer
|
||||
// bounds ACK and output latency without creating a recurring wakeup loop.
|
||||
},
|
||||
cancel: () => {
|
||||
generation += 1
|
||||
cancelHandles()
|
||||
},
|
||||
isPending: () => frameId !== null || fallbackTimer !== null
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,39 @@ function createForegroundTerminal() {
|
||||
}
|
||||
}
|
||||
|
||||
function installManualAnimationFrames(): {
|
||||
pendingCount: () => number
|
||||
runNext: (timestamp?: number) => void
|
||||
} {
|
||||
let nextId = 1
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
callbacks.set(id, callback)
|
||||
return id
|
||||
})
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'cancelAnimationFrame',
|
||||
vi.fn((id: number) => {
|
||||
callbacks.delete(id)
|
||||
})
|
||||
)
|
||||
return {
|
||||
pendingCount: () => callbacks.size,
|
||||
runNext: (timestamp = 16) => {
|
||||
const next = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined
|
||||
if (!next) {
|
||||
throw new Error('No animation frame is pending')
|
||||
}
|
||||
callbacks.delete(next[0])
|
||||
next[1](timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScheduler() {
|
||||
vi.resetModules()
|
||||
return import('./pane-terminal-output-scheduler')
|
||||
@@ -80,6 +113,7 @@ describe('pane terminal output scheduler', () => {
|
||||
|
||||
it('credits when a queued chunk finishes parsing', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
let parsed: (() => void) | undefined
|
||||
@@ -95,7 +129,7 @@ describe('pane terminal output scheduler', () => {
|
||||
})
|
||||
expect(credit.count()).toBe(0)
|
||||
|
||||
vi.advanceTimersByTime(0)
|
||||
frames.runNext()
|
||||
expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function))
|
||||
expect(credit.count()).toBe(0)
|
||||
parsed?.()
|
||||
@@ -124,6 +158,7 @@ describe('pane terminal output scheduler', () => {
|
||||
|
||||
it('defers split-chunk credit and onParsed until the final slice parses', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
const parseCallbacks: (() => void)[] = []
|
||||
@@ -141,7 +176,7 @@ describe('pane terminal output scheduler', () => {
|
||||
ackCredit: credit.fire,
|
||||
onParsed
|
||||
})
|
||||
vi.advanceTimersByTime(0)
|
||||
frames.runNext()
|
||||
|
||||
expect(parseCallbacks).toHaveLength(3)
|
||||
parseCallbacks[0]()
|
||||
@@ -261,6 +296,7 @@ describe('pane terminal output scheduler', () => {
|
||||
|
||||
it('runs parsed callbacks after queued foreground output parses', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
let parseCallback: (() => void) | undefined
|
||||
@@ -275,7 +311,7 @@ describe('pane terminal output scheduler', () => {
|
||||
onParsed
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(0)
|
||||
frames.runNext()
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function))
|
||||
expect(onParsed).not.toHaveBeenCalled()
|
||||
@@ -570,8 +606,9 @@ describe('pane terminal output scheduler', () => {
|
||||
expect(onParsed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('defers throughput foreground output to the shared high-priority drain', async () => {
|
||||
it('defers throughput foreground output to the shared frame-oriented drain', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
@@ -585,7 +622,8 @@ describe('pane terminal output scheduler', () => {
|
||||
})
|
||||
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(0)
|
||||
expect(frames.pendingCount()).toBe(1)
|
||||
frames.runNext()
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledTimes(2)
|
||||
expect(terminal.write.mock.calls.map(([data]) => data).join('')).toBe(
|
||||
@@ -593,6 +631,116 @@ describe('pane terminal output scheduler', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('coalesces passive tiny chunks and their ACK credits into one frame write', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
const parseCallbacks: (() => void)[] = []
|
||||
terminal.write.mockImplementation((_data: string, callback?: () => void) => {
|
||||
if (callback) {
|
||||
parseCallbacks.push(callback)
|
||||
}
|
||||
})
|
||||
const credits = [vi.fn(), vi.fn(), vi.fn()]
|
||||
|
||||
for (const [index, data] of ['tiny-', 'passive-', 'stream'].entries()) {
|
||||
writeTerminalOutput(terminal, data, {
|
||||
foreground: true,
|
||||
latencySensitive: false,
|
||||
ackCredit: credits[index]
|
||||
})
|
||||
}
|
||||
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
expect(frames.pendingCount()).toBe(1)
|
||||
frames.runNext()
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledTimes(1)
|
||||
expect(terminal.write).toHaveBeenCalledWith('tiny-passive-stream', expect.any(Function))
|
||||
expect(credits.every((credit) => credit.mock.calls.length === 0)).toBe(true)
|
||||
parseCallbacks[0]?.()
|
||||
expect(credits.every((credit) => credit.mock.calls.length === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('shares one animation-frame request across passive foreground terminals', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const requestFrame = vi.mocked(globalThis.requestAnimationFrame)
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const first = createTerminal()
|
||||
const second = createTerminal()
|
||||
|
||||
writeTerminalOutput(first, 'first', { foreground: true, latencySensitive: false })
|
||||
writeTerminalOutput(second, 'second', { foreground: true, latencySensitive: false })
|
||||
|
||||
expect(requestFrame).toHaveBeenCalledTimes(1)
|
||||
frames.runNext()
|
||||
expect(first.write).toHaveBeenCalledWith('first', expect.any(Function))
|
||||
expect(second.write).toHaveBeenCalledWith('second', expect.any(Function))
|
||||
})
|
||||
|
||||
it('flushes an immediate suffix behind its ordered frame-waiting prefix', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const cancelFrame = vi.mocked(globalThis.cancelAnimationFrame)
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
writeTerminalOutput(terminal, 'passive-', {
|
||||
foreground: true,
|
||||
latencySensitive: false
|
||||
})
|
||||
writeTerminalOutput(terminal, 'reply-query', {
|
||||
foreground: true,
|
||||
latencySensitive: true
|
||||
})
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledTimes(1)
|
||||
expect(terminal.write).toHaveBeenCalledWith('passive-reply-query', expect.any(Function))
|
||||
expect(cancelFrame).toHaveBeenCalledTimes(1)
|
||||
expect(frames.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the one-shot fallback when an animation frame does not run', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 41)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
writeTerminalOutput(terminal, 'fallback', {
|
||||
foreground: true,
|
||||
latencySensitive: false
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(31)
|
||||
expect(terminal.write).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(terminal.write).toHaveBeenCalledWith('fallback', expect.any(Function))
|
||||
vi.advanceTimersByTime(32)
|
||||
expect(terminal.write).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases frame-waiting output on an explicit visibility flush', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { flushTerminalOutput, writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
|
||||
writeTerminalOutput(terminal, 'visible-again', {
|
||||
foreground: true,
|
||||
latencySensitive: false
|
||||
})
|
||||
flushTerminalOutput(terminal)
|
||||
|
||||
expect(terminal.write).toHaveBeenCalledWith('visible-again', expect.any(Function))
|
||||
expect(frames.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('coalesces synchronized foreground frame endings with immediate cursor restore bytes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
@@ -1024,6 +1172,7 @@ describe('pane terminal output scheduler', () => {
|
||||
|
||||
it('ignores unforced chunks when resolving a coalesced forced refresh', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createForegroundTerminal()
|
||||
|
||||
@@ -1037,7 +1186,7 @@ describe('pane terminal output scheduler', () => {
|
||||
foreground: true,
|
||||
latencySensitive: false
|
||||
})
|
||||
vi.advanceTimersByTime(0)
|
||||
frames.runNext()
|
||||
|
||||
expect(terminal.refresh).toHaveBeenCalledWith(0, 23)
|
||||
expect(terminal._core.refresh).not.toHaveBeenCalled()
|
||||
@@ -1096,6 +1245,7 @@ describe('pane terminal output scheduler', () => {
|
||||
|
||||
it('drains active foreground backlog before older background terminal backlog', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const backgroundA = createTerminal()
|
||||
const backgroundB = createTerminal()
|
||||
@@ -1108,7 +1258,7 @@ describe('pane terminal output scheduler', () => {
|
||||
latencySensitive: false
|
||||
})
|
||||
|
||||
vi.advanceTimersByTime(0)
|
||||
frames.runNext()
|
||||
|
||||
expect(active.write).toHaveBeenCalledWith('active', expect.any(Function))
|
||||
expect(active.write.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
@@ -1308,6 +1458,7 @@ describe('pane terminal output scheduler', () => {
|
||||
// TUI on a starved renderer grew queuedChars without bound (field
|
||||
// reports of ~1.5 GB renderer RSS before terminals froze).
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
const chunk = 'x'.repeat(512 * 1024)
|
||||
@@ -1317,7 +1468,7 @@ describe('pane terminal output scheduler', () => {
|
||||
}
|
||||
writeTerminalOutput(terminal, 'after-cap\r\n', { foreground: true, latencySensitive: false })
|
||||
|
||||
vi.advanceTimersByTime(0)
|
||||
frames.runNext()
|
||||
|
||||
const output = terminal.write.mock.calls.map(([data]) => data).join('')
|
||||
expect(output).toContain('Orca skipped a burst of terminal output')
|
||||
@@ -1347,6 +1498,7 @@ describe('pane terminal output scheduler', () => {
|
||||
|
||||
it('scales the backlog cap with the scrollback setting', async () => {
|
||||
vi.useFakeTimers()
|
||||
const frames = installManualAnimationFrames()
|
||||
const { writeTerminalOutput, configureTerminalOutputBacklogCap } = await loadScheduler()
|
||||
const terminal = createTerminal()
|
||||
const chunk = 'x'.repeat(512 * 1024)
|
||||
@@ -1357,7 +1509,7 @@ describe('pane terminal output scheduler', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false })
|
||||
}
|
||||
vi.advanceTimersByTime(0)
|
||||
frames.runNext()
|
||||
|
||||
let output = terminal.write.mock.calls.map(([data]) => data).join('')
|
||||
expect(output).not.toContain('Orca skipped')
|
||||
@@ -1368,7 +1520,7 @@ describe('pane terminal output scheduler', () => {
|
||||
for (let i = 0; i < 13; i++) {
|
||||
writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false })
|
||||
}
|
||||
vi.advanceTimersByTime(0)
|
||||
frames.runNext()
|
||||
output = terminal.write.mock.calls.map(([data]) => data).join('')
|
||||
expect(output).toContain('Orca skipped a burst of terminal output')
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS,
|
||||
terminalOutputBacklogCapChars
|
||||
} from '../../../../shared/terminal-scrollback-policy'
|
||||
import { createPaneTerminalOutputFrameGate } from './pane-terminal-output-frame-gate'
|
||||
|
||||
type TerminalOutputTarget = ForegroundTerminalOutputTarget
|
||||
|
||||
@@ -85,6 +86,7 @@ type QueueEntry = {
|
||||
onBackgroundBacklogDropped?: () => void
|
||||
backgroundBacklogDropped: boolean
|
||||
highPriority: boolean
|
||||
foregroundFrameWait: boolean
|
||||
foregroundHold: boolean
|
||||
foregroundHoldSafetyDelayMs: number
|
||||
foregroundCoalesce: boolean
|
||||
@@ -158,6 +160,7 @@ let drainImmediatePending = false
|
||||
let drainImmediateGeneration = 0
|
||||
let useMessageChannelDrain = typeof MessageChannel !== 'undefined' && !isVitestEnv()
|
||||
let drainChannel: MessageChannel | null = null
|
||||
const foregroundFrameGate = createPaneTerminalOutputFrameGate(drainFrameDeferredForegroundOutput)
|
||||
|
||||
function isVitestEnv(): boolean {
|
||||
// Why: vitest fake timers cannot advance MessageChannel macrotasks; the
|
||||
@@ -184,6 +187,15 @@ function cancelImmediateDrain(): void {
|
||||
drainImmediatePending = false
|
||||
}
|
||||
|
||||
function cancelScheduledDrain(): void {
|
||||
if (drainTimer !== null) {
|
||||
clearTimeout(drainTimer)
|
||||
drainTimer = null
|
||||
drainTimerDelayMs = null
|
||||
}
|
||||
cancelImmediateDrain()
|
||||
}
|
||||
|
||||
export function setUseMessageChannelDrainForTesting(value: boolean | null): void {
|
||||
cancelImmediateDrain()
|
||||
useMessageChannelDrain = value ?? (typeof MessageChannel !== 'undefined' && !isVitestEnv())
|
||||
@@ -347,6 +359,7 @@ function createQueueEntry(
|
||||
onBackgroundBacklogDropped: options.onBackgroundBacklogDropped,
|
||||
backgroundBacklogDropped: false,
|
||||
highPriority: true,
|
||||
foregroundFrameWait: false,
|
||||
foregroundHold: false,
|
||||
foregroundHoldSafetyDelayMs: FOREGROUND_HOLD_SAFETY_DELAY_MS,
|
||||
foregroundCoalesce: false,
|
||||
@@ -409,7 +422,46 @@ function scheduleForegroundCoalesceRelease(
|
||||
}
|
||||
|
||||
function isEntryDrainable(entry: QueueEntry): boolean {
|
||||
return !entry.foregroundHold && !entry.foregroundCoalesce
|
||||
return !entry.foregroundFrameWait && !entry.foregroundHold && !entry.foregroundCoalesce
|
||||
}
|
||||
|
||||
function hasForegroundFrameWait(): boolean {
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
if (entry.foregroundFrameWait) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function releaseForegroundFrameWait(entry: QueueEntry): void {
|
||||
if (!entry.foregroundFrameWait) {
|
||||
return
|
||||
}
|
||||
entry.foregroundFrameWait = false
|
||||
if (!hasForegroundFrameWait()) {
|
||||
foregroundFrameGate.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleForegroundFrameDrain(entry: QueueEntry): void {
|
||||
entry.foregroundFrameWait = true
|
||||
if (debugEnabled && !foregroundFrameGate.isPending()) {
|
||||
debugState.scheduledDrainCount++
|
||||
}
|
||||
foregroundFrameGate.schedule()
|
||||
}
|
||||
|
||||
function drainFrameDeferredForegroundOutput(): void {
|
||||
for (const entry of queuedByTerminal.values()) {
|
||||
entry.foregroundFrameWait = false
|
||||
}
|
||||
// Why: the frame is the drain opportunity; avoid posting a second renderer
|
||||
// task while preserving the existing cooperative continuation for backlogs.
|
||||
cancelScheduledDrain()
|
||||
if (queuedByTerminal.size > 0 && hasDrainableBacklog()) {
|
||||
drainQueuedOutput()
|
||||
}
|
||||
}
|
||||
|
||||
function findCursorPositionSequenceEnd(
|
||||
@@ -1091,6 +1143,7 @@ export function writeTerminalOutput(
|
||||
if (options.foreground) {
|
||||
const entry = queuedByTerminal.get(terminal)
|
||||
if (entry?.highPriority || options.coalesceForeground || options.holdForeground) {
|
||||
const wasWaitingForForegroundFrame = entry?.foregroundFrameWait === true
|
||||
const queued = entry ?? createQueueEntry(terminal, options)
|
||||
queued.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped
|
||||
queued.highPriority = true
|
||||
@@ -1117,6 +1170,7 @@ export function writeTerminalOutput(
|
||||
return
|
||||
}
|
||||
if (options.holdForeground) {
|
||||
releaseForegroundFrameWait(queued)
|
||||
// Why: synchronized-output start/body chunks contain transient cursor
|
||||
// moves. Holding them prevents Chromium from rasterizing those states.
|
||||
if (options.latencySensitive === true) {
|
||||
@@ -1136,6 +1190,7 @@ export function writeTerminalOutput(
|
||||
return
|
||||
}
|
||||
if (options.coalesceForeground || queued.foregroundCoalesce) {
|
||||
releaseForegroundFrameWait(queued)
|
||||
queued.foregroundHold = false
|
||||
clearForegroundHoldSafety(queued)
|
||||
const shouldShortenCoalesceForLatencySensitiveForeground = options.latencySensitive === true
|
||||
@@ -1166,6 +1221,17 @@ export function writeTerminalOutput(
|
||||
queued.foregroundHold = false
|
||||
clearForegroundCoalesce(queued)
|
||||
clearForegroundHoldSafety(queued)
|
||||
if (options.latencySensitive === false) {
|
||||
scheduleForegroundFrameDrain(queued)
|
||||
return
|
||||
}
|
||||
releaseForegroundFrameWait(queued)
|
||||
if (wasWaitingForForegroundFrame) {
|
||||
// Why: a query or input echo cannot overtake passive bytes that were
|
||||
// already waiting for paint; flush the ordered prefix and suffix now.
|
||||
flushTerminalOutput(terminal)
|
||||
return
|
||||
}
|
||||
scheduleDrain(0)
|
||||
return
|
||||
}
|
||||
@@ -1221,9 +1287,9 @@ export function writeTerminalOutput(
|
||||
replaceBacklogWithWarning(queued, FOREGROUND_BACKLOG_WARNING)
|
||||
}
|
||||
// Why: visible command floods are throughput work, not keystroke echo.
|
||||
// Queue them behind a zero-delay drain so one IPC callback cannot pin
|
||||
// the renderer in xterm.write while input and paint are waiting.
|
||||
scheduleDrain(0)
|
||||
// Wait for the next paint opportunity so tiny passive deliveries share
|
||||
// one xterm write; large backlogs keep the existing fast continuation.
|
||||
scheduleForegroundFrameDrain(queued)
|
||||
return
|
||||
}
|
||||
flushTerminalOutput(terminal)
|
||||
@@ -1295,6 +1361,7 @@ export function flushTerminalOutput(
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
releaseForegroundFrameWait(entry)
|
||||
queuedByTerminal.delete(terminal)
|
||||
if (!isEntryDrainable(entry)) {
|
||||
queuedByTerminal.set(terminal, entry)
|
||||
@@ -1439,6 +1506,9 @@ export function discardTerminalOutput(terminal: TerminalOutputTarget): void {
|
||||
}
|
||||
discardInFlightTerminalOutputAckCredits(terminal)
|
||||
queuedByTerminal.delete(terminal)
|
||||
if (!hasForegroundFrameWait()) {
|
||||
foregroundFrameGate.cancel()
|
||||
}
|
||||
discardForegroundRenderSettle(terminal)
|
||||
// Why: a legitimately disposed pane must not leave a pending stall watch to
|
||||
// probe the dead terminal later and report a false wedge.
|
||||
|
||||
Reference in New Issue
Block a user