mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
perf(main): remove four per-chunk/per-waiter hot-path costs in PTY and terminal-wait (#18315)
Four independent wastes on the main process, none of which changes behavior: - One shared 2s sweep replaces one setInterval per terminal-wait waiter. 20 waiters allocated 20 handles and 10 main wakeups/s independent of output; now 1 handle and 0.5 wakeups/s. Same cadence, same per-waiter checks in the same order, same resolve semantics; the foregroundPollInFlight latch moved into the waiter's poll entry unchanged and each entry still interleaves its own foreground read, so one slow ps cannot delay another waiter. - SIGWINCH's `ps` for Orca's own row is memoized. It reads this process's controlling tty, which is invariant for the process lifetime, and feeds exactly one guard. Exec count per 4-pane tab switch drops 16 -> 8. The call stays synchronous: making it async would reorder SIGWINCH against subsequent writes. - The wait-blocked carry retains chunks with a running char count instead of concatenating and re-slicing a 256KB window on every chunk, and joins once at scan time. runWaitBlockedCheck receives a byte-identical `appended`. - maxUpwardCursorReach no longer compiles a RegExp per redraw chunk, and containsTerminalVerticalLineControl walks with charCodeAt instead of minting a one-char string per position.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
getPosixPtyForegroundGroup,
|
||||
resetPosixPtyForegroundGroupOwnRowCache,
|
||||
signalPosixPtyForegroundGroup
|
||||
} from './posix-pty-foreground-group'
|
||||
|
||||
@@ -137,6 +138,10 @@ describe('signalPosixPtyForegroundGroup', () => {
|
||||
})
|
||||
|
||||
describe('process table lookup', () => {
|
||||
beforeEach(() => {
|
||||
resetPosixPtyForegroundGroupOwnRowCache()
|
||||
})
|
||||
|
||||
it('asks ps for one pid at a time', () => {
|
||||
// Why pinned: macOS ps only takes its by-pid fast path for a SINGLE pid. Any list
|
||||
// form walks the whole process table (~3.6s on a busy machine vs ~3ms), which
|
||||
@@ -170,4 +175,53 @@ describe('process table lookup', () => {
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('forks ps once per pane after the first SIGWINCH of the process', () => {
|
||||
// Why: `runPs(currentPid)` reads Orca's own controlling tty, which cannot change
|
||||
// for the process lifetime and feeds only the "do we share this PTY" guard. The
|
||||
// renderer fires SIGWINCH twice per revealed pane, so re-forking it made a 4-pane
|
||||
// tab switch eight synchronous ~3ms `ps` calls on the main event loop.
|
||||
const execFileSyncMock = vi.mocked(execFileSync)
|
||||
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true)
|
||||
const panes = [
|
||||
{ rootPid: 900, tty: 'ttys301' },
|
||||
{ rootPid: 901, tty: 'ttys302' },
|
||||
{ rootPid: 902, tty: 'ttys303' },
|
||||
{ rootPid: 903, tty: 'ttys304' }
|
||||
]
|
||||
|
||||
try {
|
||||
execFileSyncMock.mockClear()
|
||||
execFileSyncMock.mockImplementation(((_file: string, args: string[]) => {
|
||||
const pid = Number(args[args.indexOf('-p') + 1])
|
||||
if (pid === 4242) {
|
||||
return '4242 4242 ttys002'
|
||||
}
|
||||
const pane = panes.find((entry) => entry.rootPid === pid)
|
||||
return pane ? `${pane.rootPid} ${pane.rootPid + 50} ${pane.tty}` : ''
|
||||
}) as never)
|
||||
|
||||
for (const pane of panes) {
|
||||
// Two signals per revealed pane: hidden-restore snapshot + reattach repaint.
|
||||
for (let signalIndex = 0; signalIndex < 2; signalIndex += 1) {
|
||||
signalPosixPtyForegroundGroup(pane.rootPid, `/dev/${pane.tty}`, 'SIGWINCH', vi.fn(), {
|
||||
platform: 'darwin',
|
||||
currentPid: 4242
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const pidArgs = execFileSyncMock.mock.calls.map((call) =>
|
||||
Number((call[1] as string[])[(call[1] as string[]).indexOf('-p') + 1])
|
||||
)
|
||||
// 8 root-pid reads (one per signal) + exactly ONE read of Orca's own row.
|
||||
expect(pidArgs.filter((pid) => pid === 4242)).toHaveLength(1)
|
||||
expect(pidArgs).toHaveLength(9)
|
||||
expect(kill).toHaveBeenCalledTimes(8)
|
||||
} finally {
|
||||
execFileSyncMock.mockReset()
|
||||
execFileSyncMock.mockReturnValue('' as never)
|
||||
kill.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,15 +33,37 @@ function runPs(pid: number): string {
|
||||
})
|
||||
}
|
||||
|
||||
let ownRowCache: { pid: number; row: string } | null = null
|
||||
|
||||
/**
|
||||
* Why two calls instead of `-p a,b`: macOS `ps` only takes the KERN_PROC_PID fast
|
||||
* Orca's own row, read once per process. It feeds exactly one guard — the
|
||||
* "does this process share the PTY" check below — and the only field that guard
|
||||
* reads is the controlling tty, which cannot change for a process's lifetime.
|
||||
* Re-forking `ps` for it on every SIGWINCH doubled a ~3ms synchronous stall that
|
||||
* the renderer fires twice per revealed pane.
|
||||
*/
|
||||
function readOwnProcessRow(currentPid: number): string {
|
||||
if (ownRowCache?.pid !== currentPid) {
|
||||
// A throw is not cached: the caller already treats a failed read as "no group".
|
||||
ownRowCache = { pid: currentPid, row: runPs(currentPid) }
|
||||
}
|
||||
return ownRowCache.row
|
||||
}
|
||||
|
||||
/** Test seam: the cache is keyed by pid, but tests reuse one pid across cases. */
|
||||
export function resetPosixPtyForegroundGroupOwnRowCache(): void {
|
||||
ownRowCache = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why two rows instead of `-p a,b`: macOS `ps` only takes the KERN_PROC_PID fast
|
||||
* path for a single pid. ANY pid list — even a duplicate of one pid — walks the
|
||||
* whole process table, measured at ~3.6s on a busy machine versus ~3ms here. That
|
||||
* blew the timeout below, so the group lookup silently fell back to the very
|
||||
* root-pid delivery this module exists to replace.
|
||||
*/
|
||||
function readForegroundGroupTable(rootPid: number, currentPid: number): string {
|
||||
return `${runPs(rootPid)}\n${runPs(currentPid)}`
|
||||
return `${runPs(rootPid)}\n${readOwnProcessRow(currentPid)}`
|
||||
}
|
||||
|
||||
function parseProcessRows(output: string): ProcessRow[] {
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from './runtime-terminal-state-records'
|
||||
import type { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import type { PtyProviderBufferSnapshot } from '../providers/types'
|
||||
import type { TerminalTailWaitState } from './terminal-wait-tail-state'
|
||||
import type { WaitBlockedCheckState } from './wait-blocked-check-state'
|
||||
import type { createAgentStatusOscProcessor } from '../../shared/agent-status-osc'
|
||||
import { RuntimeAgentRowStore } from './runtime-agent-row-store'
|
||||
import { RuntimeTerminalViewSubscribers } from './runtime-terminal-view-subscribers'
|
||||
@@ -94,16 +94,7 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ
|
||||
// arbitrary, so running the identical computation over coalesced chunks at
|
||||
// a bounded cadence (plus a trailing-edge timer so burst-final state is
|
||||
// always evaluated) preserves semantics while removing it from the hot path.
|
||||
protected waitBlockedCheckStateByPtyId = new Map<
|
||||
string,
|
||||
{
|
||||
lastAt: number
|
||||
lastWaitState: TerminalTailWaitState | null
|
||||
appended: string
|
||||
keywordCarry: string
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
>()
|
||||
protected waitBlockedCheckStateByPtyId = new Map<string, WaitBlockedCheckState>()
|
||||
|
||||
protected agentStatusOscProcessorsByPtyId = new Map<
|
||||
string,
|
||||
|
||||
@@ -5,8 +5,13 @@ import {
|
||||
WAIT_BLOCKED_KEYWORD_CARRY_CHARS,
|
||||
WAIT_BLOCKED_KEYWORD_PATTERN
|
||||
} from './orca-runtime-postlude'
|
||||
import { MAX_TAIL_CHARS } from './terminal-tail-limits'
|
||||
import type { TerminalTailWaitState } from './terminal-wait-tail-state'
|
||||
import {
|
||||
appendWaitBlockedCarry,
|
||||
createWaitBlockedCheckState,
|
||||
readWaitBlockedCarry,
|
||||
resetWaitBlockedCarry,
|
||||
type WaitBlockedCheckState
|
||||
} from './wait-blocked-check-state'
|
||||
import {
|
||||
computeTerminalTailWaitState,
|
||||
tailGainedNewerBlockedReason
|
||||
@@ -19,19 +24,16 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt
|
||||
protected scheduleWaitBlockedCheck(ptyId: string, appendedText: string, at: number): void {
|
||||
let state = this.waitBlockedCheckStateByPtyId.get(ptyId)
|
||||
if (!state) {
|
||||
state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null }
|
||||
state = createWaitBlockedCheckState()
|
||||
this.waitBlockedCheckStateByPtyId.set(ptyId, state)
|
||||
}
|
||||
const appendedLower = appendedText.toLowerCase()
|
||||
const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`)
|
||||
state.keywordCarry = appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS)
|
||||
// Why the cap keeps the tail: the accumulated text only anchors boundary-
|
||||
// spanning prompt detection; anything past the tail cap has scrolled out
|
||||
// of the retained tail the check reads anyway.
|
||||
state.appended =
|
||||
state.appended.length + appendedText.length > MAX_TAIL_CHARS
|
||||
? `${state.appended}${appendedText}`.slice(-MAX_TAIL_CHARS)
|
||||
: `${state.appended}${appendedText}`
|
||||
// Why lowercase the joined window and not the chunk: the carry is already
|
||||
// lowercase, so this is one folded copy instead of a discarded per-chunk copy
|
||||
// plus the concatenation the pattern flattens anyway.
|
||||
const keywordWindow = `${state.keywordCarry}${appendedText}`.toLowerCase()
|
||||
const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(keywordWindow)
|
||||
state.keywordCarry = keywordWindow.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS)
|
||||
appendWaitBlockedCarry(state.appended, appendedText)
|
||||
const elapsed = at - state.lastAt
|
||||
if (keywordHit || elapsed >= WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS || elapsed < 0) {
|
||||
this.runWaitBlockedCheck(ptyId, state, at)
|
||||
@@ -48,20 +50,10 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt
|
||||
}
|
||||
}
|
||||
|
||||
protected runWaitBlockedCheck(
|
||||
ptyId: string,
|
||||
state: {
|
||||
lastAt: number
|
||||
lastWaitState: TerminalTailWaitState | null
|
||||
appended: string
|
||||
keywordCarry: string
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
},
|
||||
at: number
|
||||
): void {
|
||||
protected runWaitBlockedCheck(ptyId: string, state: WaitBlockedCheckState, at: number): void {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (!pty) {
|
||||
state.appended = ''
|
||||
resetWaitBlockedCarry(state.appended)
|
||||
return
|
||||
}
|
||||
const nextWaitState = computeTerminalTailWaitState(
|
||||
@@ -74,13 +66,19 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt
|
||||
signal: null,
|
||||
fromTail: false
|
||||
}
|
||||
if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) {
|
||||
if (
|
||||
tailGainedNewerBlockedReason(
|
||||
previousWaitState,
|
||||
nextWaitState,
|
||||
readWaitBlockedCarry(state.appended)
|
||||
)
|
||||
) {
|
||||
pty.waitBlockedAt = at
|
||||
this.recordAgentPromptPermissionObservation(ptyId)
|
||||
}
|
||||
state.lastAt = at
|
||||
state.lastWaitState = nextWaitState
|
||||
state.appended = ''
|
||||
resetWaitBlockedCarry(state.appended)
|
||||
}
|
||||
|
||||
// Why: the scanner's first run after a restore seed compares against a null
|
||||
@@ -95,7 +93,7 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt
|
||||
}
|
||||
let state = this.waitBlockedCheckStateByPtyId.get(ptyId)
|
||||
if (!state) {
|
||||
state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null }
|
||||
state = createWaitBlockedCheckState()
|
||||
this.waitBlockedCheckStateByPtyId.set(ptyId, state)
|
||||
}
|
||||
if (state.lastWaitState === null) {
|
||||
|
||||
@@ -72,9 +72,8 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
|
||||
return
|
||||
}
|
||||
const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason)
|
||||
if (waiter.pollInterval) {
|
||||
clearInterval(waiter.pollInterval)
|
||||
waiter.pollInterval = null
|
||||
if (waiter.cancelIdlePoll) {
|
||||
waiter.cancelIdlePoll()
|
||||
}
|
||||
this.terminalWaiters.resolve(waiter, result)
|
||||
})
|
||||
|
||||
@@ -157,7 +157,8 @@ export type TerminalWaiter = {
|
||||
resolve: (result: RuntimeTerminalWait) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: NodeJS.Timeout | null
|
||||
pollInterval: NodeJS.Timeout | null
|
||||
/** Retires this waiter from the shared idle-poll sweep; null when not polling. */
|
||||
cancelIdlePoll: (() => void) | null
|
||||
abortCleanup: (() => void) | null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
|
||||
import type { TerminalWaiter } from './runtime-terminal-contracts'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
|
||||
|
||||
const INTERVAL_MS = 2000
|
||||
|
||||
function makePty(ptyId: string, overrides: Partial<RuntimePtyWorktreeRecord> = {}) {
|
||||
return {
|
||||
ptyId,
|
||||
connected: true,
|
||||
lastExitCode: null,
|
||||
lastExitCause: null,
|
||||
lastAgentStatus: null,
|
||||
lastOutputAt: null,
|
||||
tailBuffer: [],
|
||||
tailPartialLine: '',
|
||||
preview: '',
|
||||
...overrides
|
||||
} as unknown as RuntimePtyWorktreeRecord
|
||||
}
|
||||
|
||||
function makeLeaf(tabId: string, overrides: Partial<RuntimeLeafRecord> = {}) {
|
||||
return {
|
||||
tabId,
|
||||
ptyId: `${tabId}-pty`,
|
||||
connected: true,
|
||||
lastExitCode: null,
|
||||
lastExitCause: null,
|
||||
lastAgentStatus: null,
|
||||
lastOutputAt: null,
|
||||
paneTitle: null,
|
||||
tailBuffer: [],
|
||||
tailPartialLine: '',
|
||||
preview: '',
|
||||
...overrides
|
||||
} as unknown as RuntimeLeafRecord
|
||||
}
|
||||
|
||||
function makeWaiter(handle: string): TerminalWaiter {
|
||||
return {
|
||||
handle,
|
||||
condition: 'tui-idle',
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
timeout: null,
|
||||
cancelIdlePoll: null,
|
||||
abortCleanup: null
|
||||
}
|
||||
}
|
||||
|
||||
describe('RuntimeTerminalIdlePolls timer budget', () => {
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setIntervalSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('allocates one interval for 20 concurrent waiters and still resolves them on the first tick', () => {
|
||||
const resolved: { handle: string; result: RuntimeTerminalWait }[] = []
|
||||
const polls = new RuntimeTerminalIdlePolls({
|
||||
intervalMs: INTERVAL_MS,
|
||||
quiescenceMs: 1500,
|
||||
getTabTitle: () => null,
|
||||
getForegroundProcess: () => null,
|
||||
getAdoptedPtyIdleStatus: () => null,
|
||||
resolve: (waiter, result) => resolved.push({ handle: waiter.handle, result })
|
||||
})
|
||||
|
||||
const waiters = Array.from({ length: 20 }, (_, index) => {
|
||||
const waiter = makeWaiter(`handle-${index}`)
|
||||
// Already idle: an independent interval would have resolved this on its own
|
||||
// first tick at exactly intervalMs, and so must the shared sweep.
|
||||
polls.startPty(waiter, makePty(`pty-${index}`, { lastAgentStatus: 'idle' }))
|
||||
return waiter
|
||||
})
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
expect(polls.activeTimerCount).toBe(1)
|
||||
expect(resolved).toHaveLength(0)
|
||||
|
||||
vi.advanceTimersByTime(INTERVAL_MS - 1)
|
||||
expect(resolved).toHaveLength(0)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(resolved.map((entry) => entry.handle)).toEqual(waiters.map((waiter) => waiter.handle))
|
||||
// Every waiter retired, so the shared timer must retire with them.
|
||||
expect(polls.activeTimerCount).toBe(0)
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps one interval across mixed leaf and pty waiters and re-arms after going idle', () => {
|
||||
const polls = new RuntimeTerminalIdlePolls({
|
||||
intervalMs: INTERVAL_MS,
|
||||
quiescenceMs: 1500,
|
||||
getTabTitle: () => null,
|
||||
getForegroundProcess: () => null,
|
||||
getAdoptedPtyIdleStatus: () => null,
|
||||
resolve: () => {}
|
||||
})
|
||||
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
polls.startPty(makeWaiter(`pty-handle-${index}`), makePty(`pty-${index}`))
|
||||
polls.startLeaf(makeWaiter(`leaf-handle-${index}`), makeLeaf(`tab-${index}`))
|
||||
}
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(INTERVAL_MS * 5)
|
||||
// Nothing resolved: still exactly one live handle after 5 sweeps.
|
||||
expect(polls.activeTimerCount).toBe(1)
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retires the shared timer when the last waiter is cancelled through the waiter record', () => {
|
||||
const polls = new RuntimeTerminalIdlePolls({
|
||||
intervalMs: INTERVAL_MS,
|
||||
quiescenceMs: 1500,
|
||||
getTabTitle: () => null,
|
||||
getForegroundProcess: () => null,
|
||||
getAdoptedPtyIdleStatus: () => null,
|
||||
resolve: () => {}
|
||||
})
|
||||
const first = makeWaiter('a')
|
||||
const second = makeWaiter('b')
|
||||
polls.startPty(first, makePty('pty-a'))
|
||||
polls.startPty(second, makePty('pty-b'))
|
||||
|
||||
first.cancelIdlePoll?.()
|
||||
expect(first.cancelIdlePoll).toBeNull()
|
||||
expect(polls.activeTimerCount).toBe(1)
|
||||
|
||||
second.cancelIdlePoll?.()
|
||||
expect(polls.activeTimerCount).toBe(0)
|
||||
})
|
||||
|
||||
it('runs the foreground read per waiter without one waiter blocking another', async () => {
|
||||
const resolved: string[] = []
|
||||
const gates: ((value: string | null) => void)[] = []
|
||||
const polls = new RuntimeTerminalIdlePolls({
|
||||
intervalMs: INTERVAL_MS,
|
||||
quiescenceMs: 1500,
|
||||
getTabTitle: () => null,
|
||||
getForegroundProcess: () =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
gates.push(resolve)
|
||||
}),
|
||||
getAdoptedPtyIdleStatus: () => null,
|
||||
resolve: (waiter) => resolved.push(waiter.handle)
|
||||
})
|
||||
|
||||
polls.startPty(makeWaiter('slow'), makePty('pty-slow', { lastOutputAt: Date.now() - 10_000 }))
|
||||
polls.startPty(makeWaiter('fast'), makePty('pty-fast', { lastOutputAt: Date.now() - 10_000 }))
|
||||
|
||||
vi.advanceTimersByTime(INTERVAL_MS)
|
||||
// Both waiters issued their read in the same sweep — a sequential sweep would
|
||||
// have blocked the second behind the first's unresolved promise.
|
||||
expect(gates).toHaveLength(2)
|
||||
|
||||
gates[1]('node')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(resolved).toEqual(['fast'])
|
||||
|
||||
gates[0]('node')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(resolved).toEqual(['fast', 'slow'])
|
||||
expect(polls.activeTimerCount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -24,133 +24,183 @@ type RuntimeTerminalIdlePollDependencies = {
|
||||
resolve(waiter: TerminalWaiter, result: RuntimeTerminalWait): void
|
||||
}
|
||||
|
||||
type IdlePollEntry =
|
||||
| {
|
||||
kind: 'leaf'
|
||||
waiter: TerminalWaiter
|
||||
leaf: RuntimeLeafRecord
|
||||
foregroundPollInFlight: boolean
|
||||
}
|
||||
| {
|
||||
kind: 'pty'
|
||||
waiter: TerminalWaiter
|
||||
pty: RuntimePtyWorktreeRecord
|
||||
foregroundPollInFlight: boolean
|
||||
}
|
||||
|
||||
export class RuntimeTerminalIdlePolls {
|
||||
private readonly entries = new Set<IdlePollEntry>()
|
||||
private sweepTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(private readonly deps: RuntimeTerminalIdlePollDependencies) {}
|
||||
|
||||
startLeaf(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void {
|
||||
let foregroundPollInFlight = false
|
||||
waiter.pollInterval = setInterval(async () => {
|
||||
if (!waiter.pollInterval) {
|
||||
return
|
||||
}
|
||||
let startedForegroundPoll = false
|
||||
try {
|
||||
if (leaf.lastAgentStatus === 'idle') {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
const title = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId)
|
||||
if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
|
||||
const blockedReason = detectTerminalWaitBlockedReason(waitText)
|
||||
if (blockedReason) {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(
|
||||
waiter,
|
||||
buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (isKnownReadyPromptPreview(waitText)) {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
if (leaf.lastAgentStatus === null && leaf.ptyId && !foregroundPollInFlight) {
|
||||
const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId)
|
||||
if (!foregroundRead) {
|
||||
return
|
||||
}
|
||||
foregroundPollInFlight = true
|
||||
startedForegroundPoll = true
|
||||
const foreground = await foregroundRead
|
||||
if (
|
||||
foreground &&
|
||||
!isShellProcess(foreground) &&
|
||||
(leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0) >= this.deps.quiescenceMs
|
||||
) {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Transient process inspection errors do not retire the waiter.
|
||||
} finally {
|
||||
if (startedForegroundPoll) {
|
||||
foregroundPollInFlight = false
|
||||
}
|
||||
}
|
||||
}, this.deps.intervalMs)
|
||||
this.start({ kind: 'leaf', waiter, leaf, foregroundPollInFlight: false })
|
||||
}
|
||||
|
||||
startPty(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord): void {
|
||||
let foregroundPollInFlight = false
|
||||
waiter.pollInterval = setInterval(async () => {
|
||||
if (!waiter.pollInterval) {
|
||||
return
|
||||
}
|
||||
let startedForegroundPoll = false
|
||||
try {
|
||||
if (pty.lastAgentStatus === 'idle') {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
|
||||
return
|
||||
}
|
||||
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
|
||||
const blockedReason = detectTerminalWaitBlockedReason(waitText)
|
||||
if (blockedReason) {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(
|
||||
waiter,
|
||||
buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
|
||||
isKnownReadyPromptPreview(waitText)
|
||||
) {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
|
||||
return
|
||||
}
|
||||
if (pty.lastAgentStatus === null && !foregroundPollInFlight) {
|
||||
const foregroundRead = this.deps.getForegroundProcess(pty.ptyId)
|
||||
if (!foregroundRead) {
|
||||
return
|
||||
}
|
||||
foregroundPollInFlight = true
|
||||
startedForegroundPoll = true
|
||||
const foreground = await foregroundRead
|
||||
if (
|
||||
foreground &&
|
||||
!isShellProcess(foreground) &&
|
||||
(pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0) >= this.deps.quiescenceMs
|
||||
) {
|
||||
this.stop(waiter)
|
||||
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Transient process inspection errors do not retire the waiter.
|
||||
} finally {
|
||||
if (startedForegroundPoll) {
|
||||
foregroundPollInFlight = false
|
||||
}
|
||||
}
|
||||
}, this.deps.intervalMs)
|
||||
this.start({ kind: 'pty', waiter, pty, foregroundPollInFlight: false })
|
||||
}
|
||||
|
||||
private stop(waiter: TerminalWaiter): void {
|
||||
if (!waiter.pollInterval) {
|
||||
/** Test/diagnostic seam: live sweep handles, which must stay at most one. */
|
||||
get activeTimerCount(): number {
|
||||
return this.sweepTimer ? 1 : 0
|
||||
}
|
||||
|
||||
private start(entry: IdlePollEntry): void {
|
||||
this.entries.add(entry)
|
||||
entry.waiter.cancelIdlePoll = () => this.stop(entry)
|
||||
// Why one shared timer for every waiter: a per-waiter interval multiplied idle
|
||||
// main-process wakeups by the number of concurrent `wait` calls, independent of
|
||||
// whether any terminal produced output. Same shape as the synthetic-title spinner.
|
||||
if (!this.sweepTimer) {
|
||||
this.sweepTimer = setInterval(() => this.sweep(), this.deps.intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
private sweep(): void {
|
||||
// Why a snapshot and no await: each entry must run its checks and then interleave
|
||||
// its own foreground read exactly as an independent interval callback did — one
|
||||
// slow `ps` must never delay another waiter's checks, and a waiter registered by a
|
||||
// resolve inside this sweep must wait for the next tick, as a fresh interval would.
|
||||
for (const entry of Array.from(this.entries)) {
|
||||
void (entry.kind === 'leaf' ? this.tickLeaf(entry) : this.tickPty(entry))
|
||||
}
|
||||
}
|
||||
|
||||
private async tickLeaf(entry: IdlePollEntry & { kind: 'leaf' }): Promise<void> {
|
||||
if (!this.entries.has(entry)) {
|
||||
return
|
||||
}
|
||||
clearInterval(waiter.pollInterval)
|
||||
waiter.pollInterval = null
|
||||
const { waiter, leaf } = entry
|
||||
let startedForegroundPoll = false
|
||||
try {
|
||||
if (leaf.lastAgentStatus === 'idle') {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
const title = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId)
|
||||
if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
|
||||
const blockedReason = detectTerminalWaitBlockedReason(waitText)
|
||||
if (blockedReason) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(
|
||||
waiter,
|
||||
buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (isKnownReadyPromptPreview(waitText)) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
return
|
||||
}
|
||||
if (leaf.lastAgentStatus === null && leaf.ptyId && !entry.foregroundPollInFlight) {
|
||||
const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId)
|
||||
if (!foregroundRead) {
|
||||
return
|
||||
}
|
||||
entry.foregroundPollInFlight = true
|
||||
startedForegroundPoll = true
|
||||
const foreground = await foregroundRead
|
||||
if (
|
||||
foreground &&
|
||||
!isShellProcess(foreground) &&
|
||||
(leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0) >= this.deps.quiescenceMs
|
||||
) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Transient process inspection errors do not retire the waiter.
|
||||
} finally {
|
||||
if (startedForegroundPoll) {
|
||||
entry.foregroundPollInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async tickPty(entry: IdlePollEntry & { kind: 'pty' }): Promise<void> {
|
||||
if (!this.entries.has(entry)) {
|
||||
return
|
||||
}
|
||||
const { waiter, pty } = entry
|
||||
let startedForegroundPoll = false
|
||||
try {
|
||||
if (pty.lastAgentStatus === 'idle') {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
|
||||
return
|
||||
}
|
||||
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
|
||||
const blockedReason = detectTerminalWaitBlockedReason(waitText)
|
||||
if (blockedReason) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(
|
||||
waiter,
|
||||
buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
|
||||
isKnownReadyPromptPreview(waitText)
|
||||
) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
|
||||
return
|
||||
}
|
||||
if (pty.lastAgentStatus === null && !entry.foregroundPollInFlight) {
|
||||
const foregroundRead = this.deps.getForegroundProcess(pty.ptyId)
|
||||
if (!foregroundRead) {
|
||||
return
|
||||
}
|
||||
entry.foregroundPollInFlight = true
|
||||
startedForegroundPoll = true
|
||||
const foreground = await foregroundRead
|
||||
if (
|
||||
foreground &&
|
||||
!isShellProcess(foreground) &&
|
||||
(pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0) >= this.deps.quiescenceMs
|
||||
) {
|
||||
this.stop(entry)
|
||||
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Transient process inspection errors do not retire the waiter.
|
||||
} finally {
|
||||
if (startedForegroundPoll) {
|
||||
entry.foregroundPollInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private stop(entry: IdlePollEntry): void {
|
||||
if (!this.entries.delete(entry)) {
|
||||
return
|
||||
}
|
||||
entry.waiter.cancelIdlePoll = null
|
||||
if (this.entries.size === 0 && this.sweepTimer) {
|
||||
clearInterval(this.sweepTimer)
|
||||
this.sweepTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export class RuntimeTerminalWait {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: null,
|
||||
pollInterval: null,
|
||||
cancelIdlePoll: null,
|
||||
abortCleanup: null
|
||||
}
|
||||
if (!this.waiters.bindAbort(waiter, options?.signal)) {
|
||||
@@ -177,7 +177,7 @@ export class RuntimeTerminalWait {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: null,
|
||||
pollInterval: null,
|
||||
cancelIdlePoll: null,
|
||||
abortCleanup: null
|
||||
}
|
||||
|
||||
|
||||
@@ -60,8 +60,8 @@ export class RuntimeTerminalWaiterRegistry {
|
||||
if (waiter.timeout) {
|
||||
clearTimeout(waiter.timeout)
|
||||
}
|
||||
if (waiter.pollInterval) {
|
||||
clearInterval(waiter.pollInterval)
|
||||
if (waiter.cancelIdlePoll) {
|
||||
waiter.cancelIdlePoll()
|
||||
}
|
||||
if (waiter.abortCleanup) {
|
||||
waiter.abortCleanup()
|
||||
|
||||
@@ -59,9 +59,12 @@ export function hasCanonicalNumericCsiParams(params: string): boolean {
|
||||
return /^[0-9;]*$/.test(params)
|
||||
}
|
||||
|
||||
const ESCAPE_CHAR_CODE = 0x1b
|
||||
|
||||
export function containsTerminalVerticalLineControl(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value[index] !== '\u001b') {
|
||||
// Why charCodeAt: `value[index]` mints a one-char string per position on every chunk.
|
||||
if (value.charCodeAt(index) !== ESCAPE_CHAR_CODE) {
|
||||
continue
|
||||
}
|
||||
const parsed = parseAnsiControlSequence(value, index)
|
||||
|
||||
@@ -110,14 +110,19 @@ export function appendNormalizedToTailBuffer(
|
||||
// Why a window: the unwindowed impl below is O(tail) per chunk (~93% of the event loop under TUI flood, findings log 2026-07-03); a redraw only touches rows the cursor reaches, so window the suffix and share the prefix by reference. Equivalence fuzz-verified in retained-tail-redraw-window.equivalence.test.ts.
|
||||
const REDRAW_WINDOW_SAFETY_ROWS = 8
|
||||
|
||||
// Why module-level: this ran `new RegExp` per redraw chunk — i.e. per TUI frame per PTY.
|
||||
// Safe to share because `maxUpwardCursorReach` is synchronous and non-reentrant; it resets
|
||||
// `lastIndex` before every scan.
|
||||
const CURSOR_UP_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[(\\d*)(?:;[\\d;]*)?A`, 'g')
|
||||
|
||||
function maxUpwardCursorReach(
|
||||
normalizedChunk: string,
|
||||
previousRedrawCursor: RetainedTailRedrawCursor | null
|
||||
): number {
|
||||
let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0
|
||||
const cursorUpPattern = new RegExp(`${String.fromCharCode(27)}\\[(\\d*)(?:;[\\d;]*)?A`, 'g')
|
||||
CURSOR_UP_PATTERN.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = cursorUpPattern.exec(normalizedChunk)) !== null) {
|
||||
while ((match = CURSOR_UP_PATTERN.exec(normalizedChunk)) !== null) {
|
||||
reach += match[1] ? Number.parseInt(match[1], 10) : 1
|
||||
}
|
||||
return reach
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWaitBlockedCarry,
|
||||
createWaitBlockedAppendedCarry,
|
||||
readWaitBlockedCarry,
|
||||
resetWaitBlockedCarry
|
||||
} from './wait-blocked-check-state'
|
||||
import { MAX_TAIL_CHARS } from './terminal-tail-limits'
|
||||
|
||||
/** The accumulation this replaced, verbatim, as the equivalence oracle. */
|
||||
function referenceAppend(previous: string, chunk: string): string {
|
||||
return previous.length + chunk.length > MAX_TAIL_CHARS
|
||||
? `${previous}${chunk}`.slice(-MAX_TAIL_CHARS)
|
||||
: `${previous}${chunk}`
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
describe('wait-blocked appended carry', () => {
|
||||
it('is byte-identical to the concat+slice carry across a 1MB flood in one 50ms window', () => {
|
||||
// One 50ms throttle window under a TUI flood: ~1MB of output arrives as many
|
||||
// chunks and `runWaitBlockedCheck` must observe exactly the bytes the old
|
||||
// rolling window would have handed it.
|
||||
const carry = createWaitBlockedAppendedCarry()
|
||||
let reference = ''
|
||||
const rng = mulberry32(20260902)
|
||||
let produced = 0
|
||||
let chunkIndex = 0
|
||||
while (produced < 1024 * 1024) {
|
||||
const size = 1 + Math.floor(rng() * 8192)
|
||||
const chunk = `${chunkIndex}:${'█'.repeat(Math.max(0, size - 3))}\n`
|
||||
chunkIndex += 1
|
||||
produced += chunk.length
|
||||
appendWaitBlockedCarry(carry, chunk)
|
||||
reference = referenceAppend(reference, chunk)
|
||||
expect(carry.chars).toBe(reference.length)
|
||||
}
|
||||
expect(readWaitBlockedCarry(carry)).toBe(reference)
|
||||
expect(reference.length).toBe(MAX_TAIL_CHARS)
|
||||
})
|
||||
|
||||
it('matches the reference for boundary shapes: empty, exact-cap, and over-cap single chunks', () => {
|
||||
const cases: string[][] = [
|
||||
[],
|
||||
[''],
|
||||
['abc', '', 'def'],
|
||||
['x'.repeat(MAX_TAIL_CHARS)],
|
||||
['x'.repeat(MAX_TAIL_CHARS), 'y'],
|
||||
['a', 'b'.repeat(MAX_TAIL_CHARS + 5)],
|
||||
['a'.repeat(MAX_TAIL_CHARS - 1), 'bc'],
|
||||
['a'.repeat(10), 'b'.repeat(MAX_TAIL_CHARS - 10)],
|
||||
['a'.repeat(10), 'b'.repeat(MAX_TAIL_CHARS - 10), 'c']
|
||||
]
|
||||
for (const chunks of cases) {
|
||||
const carry = createWaitBlockedAppendedCarry()
|
||||
let reference = ''
|
||||
for (const chunk of chunks) {
|
||||
appendWaitBlockedCarry(carry, chunk)
|
||||
reference = referenceAppend(reference, chunk)
|
||||
}
|
||||
expect(readWaitBlockedCarry(carry)).toBe(reference)
|
||||
expect(carry.chars).toBe(reference.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('retains chunks instead of flattening the window on every chunk', () => {
|
||||
// The named antipattern: once a window exceeds the cap, the old `.slice(-cap)`
|
||||
// copied 256K chars per chunk. Retained chunks copy only the straddling head.
|
||||
const carry = createWaitBlockedAppendedCarry()
|
||||
const chunk = 'z'.repeat(32 * 1024)
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
appendWaitBlockedCarry(carry, chunk)
|
||||
}
|
||||
expect(carry.chars).toBe(MAX_TAIL_CHARS)
|
||||
expect(carry.chunks.length).toBe(8)
|
||||
|
||||
appendWaitBlockedCarry(carry, 'tail')
|
||||
expect(carry.chars).toBe(MAX_TAIL_CHARS)
|
||||
// Head trimmed in place by 4 chars; no full-window copy.
|
||||
expect(carry.chunks.length).toBe(9)
|
||||
expect(carry.chunks[0].length).toBe(chunk.length - 4)
|
||||
})
|
||||
|
||||
it('reset empties the window without leaking retained chunks', () => {
|
||||
const carry = createWaitBlockedAppendedCarry()
|
||||
appendWaitBlockedCarry(carry, 'hello')
|
||||
resetWaitBlockedCarry(carry)
|
||||
expect(readWaitBlockedCarry(carry)).toBe('')
|
||||
expect(carry.chars).toBe(0)
|
||||
expect(carry.chunks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { MAX_TAIL_CHARS } from './terminal-tail-limits'
|
||||
import type { TerminalTailWaitState } from './terminal-wait-tail-state'
|
||||
|
||||
/**
|
||||
* The capped rolling window of output appended since the last wait-blocked scan.
|
||||
*
|
||||
* Why chunks instead of one string: the scan is throttled to 50ms but the
|
||||
* accumulation is per chunk, so concatenating and re-slicing a 256KB window on
|
||||
* every PTY frame flattened the whole window per frame once a burst filled it.
|
||||
* Chunks are retained with a running char count and joined once, at scan time.
|
||||
*/
|
||||
export type WaitBlockedAppendedCarry = {
|
||||
chunks: string[]
|
||||
chars: number
|
||||
}
|
||||
|
||||
export type WaitBlockedCheckState = {
|
||||
lastAt: number
|
||||
lastWaitState: TerminalTailWaitState | null
|
||||
appended: WaitBlockedAppendedCarry
|
||||
keywordCarry: string
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
export function createWaitBlockedAppendedCarry(): WaitBlockedAppendedCarry {
|
||||
return { chunks: [], chars: 0 }
|
||||
}
|
||||
|
||||
export function createWaitBlockedCheckState(): WaitBlockedCheckState {
|
||||
return {
|
||||
lastAt: 0,
|
||||
lastWaitState: null,
|
||||
appended: createWaitBlockedAppendedCarry(),
|
||||
keywordCarry: '',
|
||||
timer: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends one chunk, dropping from the head past `MAX_TAIL_CHARS`. The cap keeps
|
||||
* the tail: the accumulated text only anchors boundary-spanning prompt detection,
|
||||
* and anything past the tail cap has scrolled out of the retained tail the check
|
||||
* reads anyway. Byte-for-byte identical to `(previous + chunk).slice(-cap)`,
|
||||
* including the partial trim of the chunk that straddles the cap boundary.
|
||||
*/
|
||||
export function appendWaitBlockedCarry(carry: WaitBlockedAppendedCarry, chunk: string): void {
|
||||
if (chunk.length === 0) {
|
||||
return
|
||||
}
|
||||
carry.chunks.push(chunk)
|
||||
carry.chars += chunk.length
|
||||
if (carry.chars <= MAX_TAIL_CHARS) {
|
||||
return
|
||||
}
|
||||
let excess = carry.chars - MAX_TAIL_CHARS
|
||||
let dropCount = 0
|
||||
while (excess > 0 && dropCount < carry.chunks.length) {
|
||||
const head = carry.chunks[dropCount]
|
||||
if (head.length <= excess) {
|
||||
excess -= head.length
|
||||
dropCount += 1
|
||||
continue
|
||||
}
|
||||
carry.chunks[dropCount] = head.slice(excess)
|
||||
excess = 0
|
||||
}
|
||||
if (dropCount > 0) {
|
||||
carry.chunks.splice(0, dropCount)
|
||||
}
|
||||
carry.chars = MAX_TAIL_CHARS
|
||||
}
|
||||
|
||||
export function readWaitBlockedCarry(carry: WaitBlockedAppendedCarry): string {
|
||||
return carry.chunks.length === 1 ? carry.chunks[0] : carry.chunks.join('')
|
||||
}
|
||||
|
||||
export function resetWaitBlockedCarry(carry: WaitBlockedAppendedCarry): void {
|
||||
carry.chunks.length = 0
|
||||
carry.chars = 0
|
||||
}
|
||||
Reference in New Issue
Block a user