mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(pty): bound cooked reply queue (#13422)
* fix(pty): bound cooked reply queue
* fix(pty): bound cooked reply queue
Implement bounded storage for cooked-echo-safe replies: 64 pending
replies and 4096 UTF-16 code units. Shed oldest replies on overflow,
never ordinary input. Add drain failure containment with generation
fencing to prevent stale operations from clearing fresh input after
clear() reuse.
* fix(pty): report pty id in drain failures
When the async drain yields, the owner may rebind to a different PTY
before the failure surfaces. Pass the failing pty id so the transport can
ignore stale failures from a rebound owner. Also tighten the pending
reply queue size bound to prevent half-written entries.
(cherry picked from commit c0c893d171)
This commit is contained in:
@@ -8159,6 +8159,166 @@
|
||||
"knownGaps": ["No manifest command yet.", "No startup color-query contract is wired."],
|
||||
"demotionRule": "Cannot promote if success is based only on absence of visible artifacts."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.cooked-reply-queue",
|
||||
"title": "Cooked-echo-safe terminal reply backlog stays bounded without evicting ordinary input",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "renderer-provider-contract",
|
||||
"surfaces": [
|
||||
"desktop terminal input",
|
||||
"OSC 10/11 replies",
|
||||
"mode-2031 replies",
|
||||
"local PTY",
|
||||
"daemon and SSH PTYs"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "Deterministic renderer and IPC-transport tests prove count and text ceilings, oldest-reply shedding, explicit query-reply source routing, ordinary-input preservation, one-reply-per-write delivery, real xterm OSC reply generation, drain-failure containment, and clear/reuse generation fencing. Live macOS Electron tests cover local PTY OSC replies and interactive typing; a macOS-hosted Docker OpenSSH test proves a real Linux SSH process receives xterm's OSC reply through the same queue. No live daemon, WSL, physical Linux/Windows client, or mixed-version run is registered.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/13137",
|
||||
"https://github.com/stablyai/orca/issues/7329"
|
||||
],
|
||||
"invariant": "The desktop PTY input queue retains at most 64 explicitly sourced pending cooked-echo-safe replies and 4096 UTF-16 code units. Overflow removes the oldest pending query-reply entries, never ordinary input or reply-shaped bytes from the ordinary path, so later user input has a finite reply backlog; every retained reply reaches the established provider path as one atomic write. Drain failures settle without an unhandled rejection and cannot clear a newer queue generation.",
|
||||
"oracle": "Synchronously enqueue 10,000 cooked-echo-safe query replies before the scheduled drain and assert that only the initial immediate reply and newest 64 pending replies are written, each as one provider write, before a trailing keystroke. Repeat behind 10,000 ordinary inputs and through the real IPC transport, requiring ordinary and reply-shaped ordinary-path bytes to survive while only the newest 64 explicit replies remain. Exercise the text ceiling, real xterm OSC 10/11 generation, provider-write failure, rejected yield, and clear/reuse generation fencing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"src/shared/terminal-query-reply.test.ts",
|
||||
"src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"tests/e2e/terminal-osc-color-queries.spec.ts",
|
||||
"tests/e2e/terminal-typing-latency.spec.ts",
|
||||
"tests/e2e/pty-input-write-queue-ssh.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts",
|
||||
"assertions": [
|
||||
"a synchronous 10,000-reply flood writes only the immediate first and newest 64 pending replies before a trailing keystroke",
|
||||
"10,000 ordinary queued inputs survive a later reply flood while reply retention remains bounded",
|
||||
"reply-shaped bytes from the ordinary input path never enter the shed-able reply budget",
|
||||
"reply count and text overflow remove only the oldest pending replies",
|
||||
"real xterm OSC 10/11 query handlers remain subject to the same retention ceiling",
|
||||
"retained cooked-echo-safe replies stay one provider write each",
|
||||
"provider-write and yield failures settle without unhandled rejection, repeated same-generation admission, or stale-generation clearing",
|
||||
"clear releases saturated reply accounting and fences in-flight validation before later input"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"sendInputImmediate applies the reply ceiling while sendInput preserves a reply-shaped ordinary payload in exact IPC write order",
|
||||
"a thrown renderer write triggers one owning-transport recovery callback and rejects later input in that queue generation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/terminal-query-reply.test.ts",
|
||||
"assertions": [
|
||||
"only complete cooked-echo-risk replies are classified and consecutive replies extract losslessly"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"assertions": [
|
||||
"repeated live query replies are delivered independently and their cooked echoes stay hidden"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-osc-color-queries.spec.ts",
|
||||
"assertions": [
|
||||
"real Electron xterm OSC 10/11 replies reach the active local PTY as separate writes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-typing-latency.spec.ts",
|
||||
"assertions": [
|
||||
"real keyboard input reaches and echoes from the active local PTY within the interactive latency ceilings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/pty-input-write-queue-ssh.spec.ts",
|
||||
"assertions": [
|
||||
"a real Linux SSH process emits an OSC query and receives xterm's synthetic reply through the live PTY queue"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/shared/terminal-query-reply.test.ts src/shared/pty-startup-ingress-live-query-reply.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.35,
|
||||
"summary": "Four files and 138 tests passed, including explicit IPC reply-source routing, the 10,000-reply count ceiling, text ceiling, 10,000-entry ordinary backlog preservation, real xterm OSC query flood, single-shot drain-failure recovery, one-reply-per-write echo containment, and clear/reuse generation fencing."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec playwright test tests/e2e/terminal-osc-color-queries.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 13.2,
|
||||
"summary": "The real Electron/xterm OSC 10/11 query path wrote the exact foreground and background replies separately to the active local PTY."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec playwright test tests/e2e/terminal-typing-latency.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 10.6,
|
||||
"summary": "Real keyboard events traversed xterm and the input queue to a local interactive PTY with every sampled key echoed inside the latency ceiling."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-09",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_SSH_DOCKER=1 pnpm exec playwright test tests/e2e/pty-input-write-queue-ssh.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 36.8,
|
||||
"summary": "A real Linux process behind Docker OpenSSH emitted an OSC 10 query and observed xterm's synthetic RGB reply after it traversed the live renderer queue, Electron IPC, SSH provider, and relay PTY."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 120,
|
||||
"scope": "focused unit, local Electron, and Docker SSH contracts"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Ten consecutive local unit-gate runs passed 1,380 tests in 29.57 seconds total with no fixed timing sleeps. The exact Docker SSH OSC oracle passed three consecutive runs before extraction into its dedicated spec and then passed the registered dedicated command once. CI and the normal soak window are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Without the branch's admission cap, the 10,000-reply fixture writes all replies before the trailing keystroke. Before the source-routing repair, the queue had no API capable of distinguishing reply-shaped ordinary input; before failure containment, a thrown provider write rejected waitForDrain and Vitest recorded an unhandled rejection; the first containment pass retried a failed generation and invoked recovery twice; before generation fencing, a rejected stale yield cleared fresh input. No saved intentional-break artifact is attached yet."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Pending cooked replies are capped at 64 and 4096 UTF-16 code units. Explicit source routing and separate sequence-merged FIFOs keep ordinary input out of the fixed-size reply store; oldest-reply eviction uses an amortized O(1) head advance without scanning ordinary input, consumed prefixes compact at a fixed threshold, and drain coalescing reads the stored classification without repeated reply-regex scans. A synchronous flood forwards only the immediate first and newest 64 replies before the trailing key. Ten complete gate runs, including two 10,000-entry flood shapes each, finished in 29.57 seconds with zero failures. The change adds no polling, timers, listeners, subprocesses, or network retries."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the gate on Windows and Linux CI.",
|
||||
"Exercise a live daemon PTY with cooked echo enabled and record exact shell-visible and provider-visible bytes.",
|
||||
"Attach an intentional-break run proving reply overflow cannot evict ordinary input or exceed either ceiling.",
|
||||
"Accumulate the normal soak window with zero unexplained flakes."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live daemon, WSL, physical Linux/Windows client, mixed-version, or network-fault run is registered.",
|
||||
"The remote-runtime transport has a separate immediate-input queue and is unaffected by this gate.",
|
||||
"Ordinary-input queue growth predates this change and is outside this reply-only gate.",
|
||||
"Reply overload intentionally sheds the oldest synthetic replies; live application behavior under a pathological query flood is not measured."
|
||||
],
|
||||
"demotionRule": "Keep experimental or revert if ordinary input is evicted, pending replies exceed either ceiling, retained replies are coalesced, a trailing key sits behind more than the bounded reply window, or the gate develops unexplained flakes."
|
||||
},
|
||||
{
|
||||
"id": "terminal-query.mobile-view-authority",
|
||||
"title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import {
|
||||
PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES,
|
||||
PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS,
|
||||
TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS,
|
||||
createPtyInputWriteQueue
|
||||
} from './pty-input-write-queue'
|
||||
@@ -7,9 +10,14 @@ import {
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES,
|
||||
TERMINAL_INPUT_MAX_BYTES
|
||||
} from '../../../../shared/terminal-input'
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text'
|
||||
import { mode2031SequenceFor } from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { PtyStartupIngress, type PtyIngressEmission } from '../../../../shared/pty-startup-ingress'
|
||||
import { extractOnlyCookedEchoSafeQueryReplies } from '../../../../shared/terminal-query-reply'
|
||||
import {
|
||||
extractOnlyCookedEchoSafeQueryReplies,
|
||||
needsCookedEchoSafeQueryReply
|
||||
} from '../../../../shared/terminal-query-reply'
|
||||
import { installTerminalCapabilityReplyHandlers } from './terminal-capability-replies'
|
||||
|
||||
const WHEEL_UP_REPORT = '\x1b[<64;60;20M'
|
||||
|
||||
@@ -27,6 +35,44 @@ function createRecordingQueue(options: { writable?: () => boolean } = {}): {
|
||||
return { writes, queue }
|
||||
}
|
||||
|
||||
function createParkedQueue(): {
|
||||
writes: WriteRecord[]
|
||||
pendingYields: (() => void)[]
|
||||
queue: ReturnType<typeof createPtyInputWriteQueue>
|
||||
} {
|
||||
const writes: WriteRecord[] = []
|
||||
const pendingYields: (() => void)[] = []
|
||||
const queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: (id, data) => writes.push({ id, data }),
|
||||
yieldBetweenWrites: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
pendingYields.push(resolve)
|
||||
})
|
||||
})
|
||||
return { writes, pendingYields, queue }
|
||||
}
|
||||
|
||||
async function releaseNextWrite(
|
||||
writes: WriteRecord[],
|
||||
pendingYields: (() => void)[]
|
||||
): Promise<void> {
|
||||
const before = writes.length
|
||||
const release = pendingYields.shift()
|
||||
expect(release).toBeDefined()
|
||||
release?.()
|
||||
await Promise.resolve()
|
||||
expect(writes.length).toBeGreaterThan(before)
|
||||
}
|
||||
|
||||
function writeTerminal(term: Terminal, data: string): Promise<void> {
|
||||
return new Promise((resolve) => term.write(data, resolve))
|
||||
}
|
||||
|
||||
function extractReplyWrites(writes: WriteRecord[]): string[] {
|
||||
return writes.flatMap((write) => extractOnlyCookedEchoSafeQueryReplies(write.data) ?? [])
|
||||
}
|
||||
|
||||
describe('pty input write queue', () => {
|
||||
it('coalesces a dense burst of wheel reports instead of one write per macrotask turn', async () => {
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
@@ -136,26 +182,21 @@ describe('pty input write queue', () => {
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const reply = mode2031SequenceFor('dark')
|
||||
|
||||
expect(queue.enqueue('pty-1', reply)).toBe(true)
|
||||
expect(queue.enqueue('pty-1', reply)).toBe(true)
|
||||
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
|
||||
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
|
||||
await queue.waitForDrain()
|
||||
|
||||
// Atomic writes so each host intercept sees a single extractable reply —
|
||||
// not one coalesced `?997;1n?997;1n` payload.
|
||||
expect(writes).toEqual([
|
||||
{ id: 'pty-1', data: reply },
|
||||
{ id: 'pty-1', data: reply }
|
||||
])
|
||||
expect(writes).toHaveLength(2)
|
||||
expect(writes[0]?.data).toBe(reply)
|
||||
expect(writes[1]?.data).toBe(reply)
|
||||
})
|
||||
|
||||
it('does not coalesce a color-scheme reply with a following keystroke', async () => {
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const reply = mode2031SequenceFor('dark')
|
||||
|
||||
queue.enqueue('pty-1', reply)
|
||||
queue.enqueueQueryReply('pty-1', reply)
|
||||
queue.enqueue('pty-1', 'y')
|
||||
await queue.waitForDrain()
|
||||
|
||||
@@ -165,6 +206,268 @@ describe('pty input write queue', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds an OSC 10/11 reply flood and drains a following keystroke', async () => {
|
||||
const { writes, pendingYields, queue } = createParkedQueue()
|
||||
const replies: string[] = []
|
||||
let allAccepted = true
|
||||
|
||||
for (let index = 0; index < 10_000; index += 1) {
|
||||
const color = index.toString(16).padStart(4, '0')
|
||||
const reply = `\x1b]${index % 2 === 0 ? 10 : 11};rgb:${color}/0000/0000\x1b\\`
|
||||
replies.push(reply)
|
||||
allAccepted = queue.enqueueQueryReply('pty-1', reply) && allAccepted
|
||||
}
|
||||
expect(allAccepted).toBe(true)
|
||||
expect(queue.enqueue('pty-1', 'k')).toBe(true)
|
||||
|
||||
await Promise.resolve()
|
||||
expect(pendingYields).toHaveLength(1)
|
||||
for (let turn = 0; turn < PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES; turn += 1) {
|
||||
await releaseNextWrite(writes, pendingYields)
|
||||
}
|
||||
await queue.waitForDrain()
|
||||
|
||||
const replyWrites = writes.filter((write) => write.data.startsWith('\x1b]'))
|
||||
expect(replyWrites.map((write) => write.data)).toEqual([
|
||||
replies[0],
|
||||
...replies.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES)
|
||||
])
|
||||
expect(replyWrites.every((write) => needsCookedEchoSafeQueryReply(write.data))).toBe(true)
|
||||
expect(writes.at(-1)?.data).toBe('k')
|
||||
})
|
||||
|
||||
it('drops the oldest reply-only payload when the reply text budget fills', async () => {
|
||||
const reply = (slot: 10 | 11, marker: string): string =>
|
||||
`\x1b]${slot};${marker.repeat(1_400)}\x1b\\`
|
||||
const first = reply(10, '1')
|
||||
const dropped = reply(11, '2')
|
||||
const second = reply(10, '3')
|
||||
const third = reply(11, '4')
|
||||
const { writes, pendingYields, queue } = createParkedQueue()
|
||||
|
||||
expect(second.length * 2).toBeLessThanOrEqual(
|
||||
PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS
|
||||
)
|
||||
expect(second.length * 3).toBeGreaterThan(PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS)
|
||||
queue.enqueueQueryReply('pty-1', first)
|
||||
queue.enqueueQueryReply('pty-1', dropped)
|
||||
queue.enqueue('pty-1', 'x')
|
||||
queue.enqueueQueryReply('pty-1', second)
|
||||
queue.enqueueQueryReply('pty-1', third)
|
||||
queue.enqueue('pty-1', 'k')
|
||||
|
||||
await Promise.resolve()
|
||||
expect(pendingYields).toHaveLength(1)
|
||||
for (let turn = 0; turn < 3; turn += 1) {
|
||||
await releaseNextWrite(writes, pendingYields)
|
||||
}
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(writes).toEqual([
|
||||
{ id: 'pty-1', data: first },
|
||||
{ id: 'pty-1', data: 'x' },
|
||||
{ id: 'pty-1', data: second },
|
||||
{ id: 'pty-1', data: third },
|
||||
{ id: 'pty-1', data: 'k' }
|
||||
])
|
||||
})
|
||||
|
||||
it('bounds replies generated by a real xterm OSC query flood', async () => {
|
||||
const term = new Terminal({ cols: 80, rows: 24, allowProposedApi: true })
|
||||
term.options.theme = { foreground: '#2e3434', background: '#ffffff' }
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const generated: string[] = []
|
||||
const disposable = installTerminalCapabilityReplyHandlers({
|
||||
terminal: {
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
element: undefined,
|
||||
options: term.options
|
||||
},
|
||||
parser: term.parser,
|
||||
sendInput: (data) => {
|
||||
generated.push(data)
|
||||
return queue.enqueueQueryReply('pty-1', data)
|
||||
},
|
||||
isReplaying: () => false
|
||||
})
|
||||
|
||||
try {
|
||||
await writeTerminal(term, '\x1b]10;?;?\x1b\\'.repeat(34))
|
||||
await queue.waitForDrain()
|
||||
|
||||
const foreground = '\x1b]10;rgb:2e2e/3434/3434\x1b\\'
|
||||
const background = '\x1b]11;rgb:ffff/ffff/ffff\x1b\\'
|
||||
expect(generated).toEqual(Array.from({ length: 34 }, () => [foreground, background]).flat())
|
||||
expect(extractReplyWrites(writes)).toEqual([
|
||||
foreground,
|
||||
...generated.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES)
|
||||
])
|
||||
} finally {
|
||||
disposable.dispose()
|
||||
term.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects one reply larger than the total reply retention budget', async () => {
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const prefix = '\x1b]10;'
|
||||
const suffix = '\x1b\\'
|
||||
const input = `${prefix}${'x'.repeat(
|
||||
TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + 1 - prefix.length - suffix.length
|
||||
)}${suffix}`
|
||||
|
||||
expect(input).toHaveLength(TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS + 1)
|
||||
expect(queue.enqueueQueryReply('pty-1', input)).toBe(false)
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves an ordinary backlog while bounding later replies', async () => {
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const ordinary = Array.from({ length: 10_000 }, (_, index) => String(index % 10))
|
||||
const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`)
|
||||
|
||||
for (const input of ordinary) {
|
||||
expect(queue.enqueue('pty-1', input)).toBe(true)
|
||||
}
|
||||
for (const reply of replies) {
|
||||
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
|
||||
}
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(writes.map((write) => write.data).join('')).toBe(
|
||||
[...ordinary, ...replies.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES)].join('')
|
||||
)
|
||||
expect(extractReplyWrites(writes)).toEqual(
|
||||
replies.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES)
|
||||
)
|
||||
})
|
||||
|
||||
it('never sheds reply-shaped bytes sent through the ordinary input path', async () => {
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const ordinary = '\x1b]10;user-ordinary-marker\x1b\\'
|
||||
const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`)
|
||||
|
||||
expect(queue.enqueueQueryReply('pty-1', '\x1b[?10000;1n')).toBe(true)
|
||||
expect(queue.enqueue('pty-1', ordinary)).toBe(true)
|
||||
for (const reply of replies) {
|
||||
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
|
||||
}
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(writes.map((write) => write.data).join('')).toContain(ordinary)
|
||||
})
|
||||
|
||||
it('contains drain failures and rejects input until queue reuse', async () => {
|
||||
const failure = new Error('write failed')
|
||||
const writes: WriteRecord[] = []
|
||||
let shouldThrow = true
|
||||
const onDrainFailure = vi.fn()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: (id, data) => {
|
||||
if (shouldThrow) {
|
||||
throw failure
|
||||
}
|
||||
writes.push({ id, data })
|
||||
},
|
||||
onDrainFailure
|
||||
})
|
||||
|
||||
try {
|
||||
expect(queue.enqueue('pty-1', 'stale')).toBe(true)
|
||||
expect(queue.enqueue('pty-1', 'also-stale')).toBe(false)
|
||||
await expect(queue.waitForDrain()).resolves.toBeUndefined()
|
||||
|
||||
shouldThrow = false
|
||||
expect(queue.enqueue('pty-1', 'still-stale')).toBe(false)
|
||||
queue.clear()
|
||||
expect(queue.enqueue('pty-1', 'fresh')).toBe(true)
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(writes).toEqual([{ id: 'pty-1', data: 'fresh' }])
|
||||
expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure)
|
||||
expect(onDrainFailure).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports the pty id that failed so a rebound owner can ignore the drain failure', async () => {
|
||||
const failure = new Error('yield failed')
|
||||
const onDrainFailure = vi.fn()
|
||||
let rejectYield: ((error: Error) => void) | undefined
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: () => undefined,
|
||||
yieldBetweenWrites: () =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectYield = reject
|
||||
}),
|
||||
onDrainFailure
|
||||
})
|
||||
|
||||
try {
|
||||
expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true)
|
||||
expect(rejectYield).toBeDefined()
|
||||
rejectYield?.(failure)
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(onDrainFailure).toHaveBeenCalledExactlyOnceWith('pty-1')
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a pending reply small enough that one drain step writes and removes it', () => {
|
||||
// Larger replies would span chunks, so admitReply could evict a half-written entry.
|
||||
expect(PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS * 3).toBeLessThanOrEqual(
|
||||
TERMINAL_INPUT_CHUNK_MAX_BYTES
|
||||
)
|
||||
})
|
||||
|
||||
it('does not let a stale drain failure clear input queued after reuse', async () => {
|
||||
const failure = new Error('stale yield failed')
|
||||
const writes: WriteRecord[] = []
|
||||
let rejectYield: ((error: Error) => void) | undefined
|
||||
let rejectNextYield = true
|
||||
const onDrainFailure = vi.fn()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const queue = createPtyInputWriteQueue({
|
||||
isWritable: () => true,
|
||||
write: (id, data) => writes.push({ id, data }),
|
||||
yieldBetweenWrites: () => {
|
||||
if (!rejectNextYield) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((_resolve, reject) => {
|
||||
rejectYield = reject
|
||||
})
|
||||
},
|
||||
onDrainFailure
|
||||
})
|
||||
|
||||
try {
|
||||
expect(queue.enqueue('pty-1', 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2))).toBe(true)
|
||||
expect(rejectYield).toBeDefined()
|
||||
queue.clear()
|
||||
rejectNextYield = false
|
||||
expect(queue.enqueue('pty-2', 'fresh')).toBe(true)
|
||||
rejectYield?.(failure)
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(writes.at(-1)).toEqual({ id: 'pty-2', data: 'fresh' })
|
||||
expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure)
|
||||
expect(onDrainFailure).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('dual mode-2031 enqueues through the real queue never paint 997 under host echo-safe write', async () => {
|
||||
// Issue path: xterm onData + mode-2031 scan each enqueue mode2031SequenceFor.
|
||||
// Drive the real write queue → host intercept (extract + answerLiveQueryReply)
|
||||
@@ -198,8 +501,8 @@ describe('pty input write queue', () => {
|
||||
write: hostWrite
|
||||
})
|
||||
|
||||
expect(queue.enqueue('pty-1', reply)).toBe(true)
|
||||
expect(queue.enqueue('pty-1', reply)).toBe(true)
|
||||
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
|
||||
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
|
||||
await queue.waitForDrain()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
@@ -243,4 +546,37 @@ describe('pty input write queue', () => {
|
||||
expect(writes.length).toBe(1)
|
||||
expect(writes.map((write) => write.data).join('')).not.toContain('tail')
|
||||
})
|
||||
|
||||
it('clear() fences an in-flight validation before queue reuse', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const stale = 'é'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS + 1)
|
||||
|
||||
try {
|
||||
expect(queue.enqueue('pty-1', stale)).toBe(true)
|
||||
queue.clear()
|
||||
expect(queue.enqueue('pty-1', 'fresh')).toBe(true)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(writes).toEqual([{ id: 'pty-1', data: 'fresh' }])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('clear() resets reply capacity for later input', async () => {
|
||||
const { writes, queue } = createRecordingQueue()
|
||||
const reply = mode2031SequenceFor('dark')
|
||||
|
||||
for (let index = 0; index <= PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES; index += 1) {
|
||||
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
|
||||
}
|
||||
queue.clear()
|
||||
expect(queue.enqueueQueryReply('pty-1', reply)).toBe(true)
|
||||
await queue.waitForDrain()
|
||||
|
||||
expect(extractReplyWrites(writes)).toEqual([reply, reply])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,10 +9,18 @@ import { needsCookedEchoSafeQueryReply } from '../../../../shared/terminal-query
|
||||
// 16KB TERMINAL_INPUT_CHUNK_MAX_BYTES cap without paying byte measurement on
|
||||
// the hot input path.
|
||||
export const TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS = 4096
|
||||
// Match host delivery's reply ceiling while keeping all retained reply text under one PTY chunk.
|
||||
export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES = 64
|
||||
// Keep ≤ TERMINAL_INPUT_CHUNK_MAX_BYTES/3 so a reply is written and dropped in one drain step:
|
||||
// admitReply evicts the head, and a half-written entry would truncate. Guarded by a unit test.
|
||||
export const PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS =
|
||||
TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS
|
||||
|
||||
type PendingPtyInputWrite = {
|
||||
sequence: number
|
||||
id: string
|
||||
text: string
|
||||
replyOnly: boolean
|
||||
tooLarge: boolean | Promise<boolean>
|
||||
chunks?: Iterator<string>
|
||||
nextChunk?: string
|
||||
@@ -20,6 +28,7 @@ type PendingPtyInputWrite = {
|
||||
|
||||
export type PtyInputWriteQueue = {
|
||||
enqueue: (id: string, data: string) => boolean
|
||||
enqueueQueryReply: (id: string, data: string) => boolean
|
||||
waitForDrain: () => Promise<void>
|
||||
clear: () => void
|
||||
}
|
||||
@@ -28,90 +37,209 @@ export type PtyInputWriteQueueDeps = {
|
||||
isWritable: (id: string) => boolean
|
||||
write: (id: string, data: string) => void
|
||||
yieldBetweenWrites?: () => Promise<void>
|
||||
onDrainFailure?: (id: string) => void
|
||||
}
|
||||
|
||||
function isCoalescibleText(text: string): boolean {
|
||||
function isCoalescibleInput(input: PendingPtyInputWrite): boolean {
|
||||
// Echo-risk replies stay atomic so host classifiers cannot miss them (#13137).
|
||||
return (
|
||||
text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS && !needsCookedEchoSafeQueryReply(text)
|
||||
)
|
||||
return input.text.length <= TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS && !input.replyOnly
|
||||
}
|
||||
|
||||
export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInputWriteQueue {
|
||||
const yieldBetweenWrites = deps.yieldBetweenWrites ?? yieldToEventLoop
|
||||
let pending: PendingPtyInputWrite[] = []
|
||||
let pendingOrdinary: (PendingPtyInputWrite | undefined)[] = []
|
||||
let pendingOrdinaryHead = 0
|
||||
let pendingReplies: (PendingPtyInputWrite | undefined)[] = []
|
||||
let pendingReplyHead = 0
|
||||
let pendingReplyCount = 0
|
||||
let pendingReplyCodeUnits = 0
|
||||
let nextSequence = 0
|
||||
let generation = 0
|
||||
let failedGeneration: number | null = null
|
||||
let drainPromise: Promise<void> | null = null
|
||||
|
||||
function compactOrdinary(): void {
|
||||
if (pendingOrdinaryHead === pendingOrdinary.length) {
|
||||
pendingOrdinary = []
|
||||
pendingOrdinaryHead = 0
|
||||
} else if (pendingOrdinaryHead >= 1024 && pendingOrdinaryHead * 2 >= pendingOrdinary.length) {
|
||||
pendingOrdinary = pendingOrdinary.slice(pendingOrdinaryHead)
|
||||
pendingOrdinaryHead = 0
|
||||
}
|
||||
}
|
||||
|
||||
function compactReplies(): void {
|
||||
if (pendingReplyHead === pendingReplies.length) {
|
||||
pendingReplies = []
|
||||
pendingReplyHead = 0
|
||||
} else if (pendingReplyHead >= 1024 && pendingReplyHead * 2 >= pendingReplies.length) {
|
||||
pendingReplies = pendingReplies.slice(pendingReplyHead)
|
||||
pendingReplyHead = 0
|
||||
}
|
||||
}
|
||||
|
||||
function resetSequenceIfEmpty(): void {
|
||||
if (pendingOrdinary.length === 0 && pendingReplies.length === 0) {
|
||||
nextSequence = 0
|
||||
}
|
||||
}
|
||||
|
||||
function firstPending(): PendingPtyInputWrite | undefined {
|
||||
const ordinary = pendingOrdinary[pendingOrdinaryHead]
|
||||
const reply = pendingReplies[pendingReplyHead]
|
||||
if (!ordinary) {
|
||||
return reply
|
||||
}
|
||||
if (!reply) {
|
||||
return ordinary
|
||||
}
|
||||
return ordinary.sequence < reply.sequence ? ordinary : reply
|
||||
}
|
||||
|
||||
function shiftOrdinary(): PendingPtyInputWrite | undefined {
|
||||
const removed = pendingOrdinary[pendingOrdinaryHead]
|
||||
pendingOrdinary[pendingOrdinaryHead] = undefined
|
||||
pendingOrdinaryHead += 1
|
||||
compactOrdinary()
|
||||
resetSequenceIfEmpty()
|
||||
return removed
|
||||
}
|
||||
|
||||
function shiftReply(): PendingPtyInputWrite | undefined {
|
||||
const removed = pendingReplies[pendingReplyHead]
|
||||
pendingReplies[pendingReplyHead] = undefined
|
||||
pendingReplyHead += 1
|
||||
if (removed) {
|
||||
pendingReplyCount -= 1
|
||||
pendingReplyCodeUnits -= removed.text.length
|
||||
}
|
||||
compactReplies()
|
||||
resetSequenceIfEmpty()
|
||||
return removed
|
||||
}
|
||||
|
||||
function removePending(item: PendingPtyInputWrite): void {
|
||||
if (item.replyOnly) {
|
||||
shiftReply()
|
||||
} else {
|
||||
shiftOrdinary()
|
||||
}
|
||||
}
|
||||
|
||||
function admitReply(text: string): boolean {
|
||||
if (text.length > PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS) {
|
||||
return false
|
||||
}
|
||||
while (
|
||||
pendingReplyCount >= PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES ||
|
||||
pendingReplyCodeUnits + text.length > PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLY_CODE_UNITS
|
||||
) {
|
||||
if (!shiftReply()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function clearPending(): void {
|
||||
pendingOrdinary = []
|
||||
pendingOrdinaryHead = 0
|
||||
pendingReplies = []
|
||||
pendingReplyHead = 0
|
||||
pendingReplyCount = 0
|
||||
pendingReplyCodeUnits = 0
|
||||
nextSequence = 0
|
||||
}
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
while (pending.length > 0) {
|
||||
const next = pending[0]
|
||||
if (!next) {
|
||||
pending.shift()
|
||||
continue
|
||||
}
|
||||
if (!deps.isWritable(next.id)) {
|
||||
pending.shift()
|
||||
continue
|
||||
}
|
||||
if (next.tooLarge !== false) {
|
||||
next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true)
|
||||
if (next.tooLarge) {
|
||||
pending.shift()
|
||||
continue
|
||||
}
|
||||
let failureGeneration = generation
|
||||
// Why: the drain yields, so the owner may rebind before the failure surfaces; report the id that actually failed.
|
||||
let failingId: string | null = null
|
||||
try {
|
||||
let next: PendingPtyInputWrite | undefined
|
||||
while ((next = firstPending())) {
|
||||
failureGeneration = generation
|
||||
failingId = next.id
|
||||
if (!deps.isWritable(next.id)) {
|
||||
pending.shift()
|
||||
removePending(next)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Why: dense input streams (SGR wheel reports during trackpad momentum,
|
||||
// key auto-repeat) enqueue one tiny item per event. Writing one item per
|
||||
// macrotask turn lets Chromium's nested-timer clamp pace the drain at
|
||||
// ≥4ms per item, so a fast gesture's reports reach the PTY seconds after
|
||||
// the gesture ended and the TUI visibly replays them one by one.
|
||||
// Coalescing consecutive validated small items into a single write keeps
|
||||
// the PTY byte stream identical while draining the backlog in one turn.
|
||||
if (next.chunks === undefined && isCoalescibleText(next.text)) {
|
||||
let payload = next.text
|
||||
pending.shift()
|
||||
while (pending.length > 0) {
|
||||
const peek = pending[0]
|
||||
if (
|
||||
!peek ||
|
||||
peek.id !== next.id ||
|
||||
peek.tooLarge !== false ||
|
||||
peek.chunks !== undefined ||
|
||||
!isCoalescibleText(peek.text) ||
|
||||
payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS
|
||||
) {
|
||||
break
|
||||
if (next.tooLarge !== false) {
|
||||
next.tooLarge = await Promise.resolve(next.tooLarge).catch(() => true)
|
||||
if (firstPending() !== next) {
|
||||
continue
|
||||
}
|
||||
if (next.tooLarge) {
|
||||
removePending(next)
|
||||
continue
|
||||
}
|
||||
if (!deps.isWritable(next.id)) {
|
||||
removePending(next)
|
||||
continue
|
||||
}
|
||||
payload += peek.text
|
||||
pending.shift()
|
||||
}
|
||||
deps.write(next.id, payload)
|
||||
if (pending.length > 0) {
|
||||
// Why: dense input streams (SGR wheel reports during trackpad momentum,
|
||||
// key auto-repeat) enqueue one tiny item per event. Writing one item per
|
||||
// macrotask turn lets Chromium's nested-timer clamp pace the drain at
|
||||
// ≥4ms per item, so a fast gesture's reports reach the PTY seconds after
|
||||
// the gesture ended and the TUI visibly replays them one by one.
|
||||
// Coalescing consecutive validated small items into a single write keeps
|
||||
// the PTY byte stream identical while draining the backlog in one turn.
|
||||
if (next.chunks === undefined && isCoalescibleInput(next)) {
|
||||
let payload = next.text
|
||||
removePending(next)
|
||||
let peek: PendingPtyInputWrite | undefined
|
||||
while ((peek = firstPending())) {
|
||||
if (
|
||||
peek.id !== next.id ||
|
||||
peek.tooLarge !== false ||
|
||||
peek.chunks !== undefined ||
|
||||
!isCoalescibleInput(peek) ||
|
||||
payload.length + peek.text.length > TERMINAL_INPUT_COALESCE_MAX_CODE_UNITS
|
||||
) {
|
||||
break
|
||||
}
|
||||
payload += peek.text
|
||||
removePending(peek)
|
||||
}
|
||||
deps.write(next.id, payload)
|
||||
if (firstPending()) {
|
||||
await yieldBetweenWrites()
|
||||
}
|
||||
continue
|
||||
}
|
||||
next.chunks ??= iterateTerminalInputChunks(next.text)
|
||||
const chunk =
|
||||
next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk }
|
||||
next.nextChunk = undefined
|
||||
if (chunk.done) {
|
||||
removePending(next)
|
||||
continue
|
||||
}
|
||||
deps.write(next.id, chunk.value)
|
||||
const following = next.chunks.next()
|
||||
if (following.done) {
|
||||
removePending(next)
|
||||
} else {
|
||||
next.nextChunk = following.value
|
||||
}
|
||||
if (firstPending()) {
|
||||
await yieldBetweenWrites()
|
||||
}
|
||||
continue
|
||||
}
|
||||
next.chunks ??= iterateTerminalInputChunks(next.text)
|
||||
const chunk =
|
||||
next.nextChunk === undefined ? next.chunks.next() : { done: false, value: next.nextChunk }
|
||||
next.nextChunk = undefined
|
||||
if (chunk.done) {
|
||||
pending.shift()
|
||||
continue
|
||||
} catch (error) {
|
||||
const failureIsCurrent = generation === failureGeneration
|
||||
if (failureIsCurrent) {
|
||||
clearPending()
|
||||
failedGeneration = generation
|
||||
}
|
||||
deps.write(next.id, chunk.value)
|
||||
const following = next.chunks.next()
|
||||
if (following.done) {
|
||||
pending.shift()
|
||||
} else {
|
||||
next.nextChunk = following.value
|
||||
}
|
||||
if (pending.length > 0) {
|
||||
await yieldBetweenWrites()
|
||||
console.warn('[pty-input-write-queue] drain failed:', error)
|
||||
if (failureIsCurrent && failingId !== null) {
|
||||
try {
|
||||
deps.onDrainFailure?.(failingId)
|
||||
} catch (recoveryError) {
|
||||
console.warn('[pty-input-write-queue] failure handler failed:', recoveryError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,27 +248,51 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
if (drainPromise) {
|
||||
return
|
||||
}
|
||||
drainPromise = drain().finally(() => {
|
||||
const finishDrain = (): void => {
|
||||
drainPromise = null
|
||||
if (pending.length > 0) {
|
||||
if (firstPending()) {
|
||||
scheduleDrain()
|
||||
}
|
||||
})
|
||||
}
|
||||
drainPromise = drain().finally(finishDrain)
|
||||
}
|
||||
|
||||
function enqueueInput(id: string, data: string, queryReply: boolean): boolean {
|
||||
try {
|
||||
if (failedGeneration === generation) {
|
||||
return false
|
||||
}
|
||||
const replyOnly = queryReply && needsCookedEchoSafeQueryReply(data)
|
||||
if (replyOnly && !admitReply(data)) {
|
||||
return false
|
||||
}
|
||||
const tooLarge = replyOnly ? false : isTerminalInputTooLargeWithDeferredMeasurement(data)
|
||||
if (tooLarge === true) {
|
||||
return false
|
||||
}
|
||||
const item = { sequence: nextSequence, id, text: data, replyOnly, tooLarge }
|
||||
nextSequence += 1
|
||||
if (replyOnly) {
|
||||
pendingReplies.push(item)
|
||||
pendingReplyCount += 1
|
||||
pendingReplyCodeUnits += data.length
|
||||
} else {
|
||||
pendingOrdinary.push(item)
|
||||
}
|
||||
scheduleDrain()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue(id: string, data: string): boolean {
|
||||
try {
|
||||
const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data)
|
||||
if (tooLarge === true) {
|
||||
return false
|
||||
}
|
||||
pending.push({ id, text: data, tooLarge })
|
||||
scheduleDrain()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return enqueueInput(id, data, false)
|
||||
},
|
||||
|
||||
enqueueQueryReply(id: string, data: string): boolean {
|
||||
return enqueueInput(id, data, true)
|
||||
},
|
||||
|
||||
async waitForDrain(): Promise<void> {
|
||||
@@ -150,7 +302,9 @@ export function createPtyInputWriteQueue(deps: PtyInputWriteQueueDeps): PtyInput
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
pending = []
|
||||
generation += 1
|
||||
failedGeneration = null
|
||||
clearPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TERMINAL_INPUT_MAX_BYTES
|
||||
} from '../../../../shared/terminal-input'
|
||||
import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text'
|
||||
import { PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES } from './pty-input-write-queue'
|
||||
|
||||
describe('createIpcPtyTransport', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
@@ -107,6 +108,29 @@ describe('createIpcPtyTransport', () => {
|
||||
transport.disconnect()
|
||||
})
|
||||
|
||||
it('routes a thrown renderer write to the owning transport recovery callback', async () => {
|
||||
const failure = new Error('ipc write failed')
|
||||
vi.mocked(window.api.pty.write).mockImplementation(() => {
|
||||
throw failure
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const recovery = vi.fn()
|
||||
const transport = createIpcPtyTransport({})
|
||||
await transport.connect({ url: '', callbacks: { onWriteUnavailable: recovery } })
|
||||
|
||||
expect(transport.sendInput('input')).toBe(true)
|
||||
expect(transport.sendInput('later-input')).toBe(false)
|
||||
|
||||
expect(recovery).toHaveBeenCalledOnce()
|
||||
expect(warn).toHaveBeenCalledWith('[pty-input-write-queue] drain failed:', failure)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not create a second kill authority when a mounted pane detaches', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const kill = window.api.pty.kill as unknown as ReturnType<typeof vi.fn>
|
||||
@@ -1132,6 +1156,36 @@ describe('createIpcPtyTransport', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds immediate cooked replies without shedding ordinary-path lookalikes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({})
|
||||
const first = '\x1b[?10000;1n'
|
||||
const ordinary = '\x1b]10;user-ordinary-marker\x1b\\'
|
||||
const replies = Array.from({ length: 10_000 }, (_, index) => `\x1b[?${index};1n`)
|
||||
|
||||
await transport.connect({ url: '', callbacks: {} })
|
||||
expect(transport.sendInputImmediate(first)).toBe(true)
|
||||
expect(transport.sendInput(ordinary)).toBe(true)
|
||||
for (const reply of replies) {
|
||||
expect(transport.sendInputImmediate(reply)).toBe(true)
|
||||
}
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(vi.mocked(window.api.pty.write).mock.calls).toEqual([
|
||||
['pty-1', first],
|
||||
['pty-1', ordinary],
|
||||
...replies
|
||||
.slice(-PTY_INPUT_WRITE_QUEUE_MAX_PENDING_REPLIES)
|
||||
.map((reply) => ['pty-1', reply])
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('yields while validating accepted large local IPC terminal input before renderer-to-main writes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
|
||||
@@ -577,9 +577,17 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
let ptyId: string | null = null
|
||||
// Why: replayed eager-buffer data (often from a prior app session) must not fire fresh bells, unread marks, or notifications on reconnect.
|
||||
let suppressAttentionEvents = false
|
||||
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
|
||||
const inputWriteQueue = createPtyInputWriteQueue({
|
||||
isWritable: (id) => connected && ptyId === id,
|
||||
write: (id, data) => window.api.pty.write(id, data)
|
||||
write: (id, data) => window.api.pty.write(id, data),
|
||||
// Guard like the registered writeUnavailable handler: a rebind during the async drain
|
||||
// must not tell the new pane that its write failed.
|
||||
onDrainFailure: (id) => {
|
||||
if (ptyId === id) {
|
||||
storedCallbacks.onWriteUnavailable?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
const outputProcessor = createPtyOutputProcessor({
|
||||
onTitleChange,
|
||||
@@ -593,8 +601,6 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
onAgentExited,
|
||||
onAgentStatus
|
||||
})
|
||||
let storedCallbacks: Parameters<PtyTransport['connect']>[0]['callbacks'] = {}
|
||||
|
||||
// Why: a new pane can attach to the same ptyId before the old instance's detach() runs; track owned handlers so unregister never deletes the live one.
|
||||
const ownedDataAndReplayHandlers = new Map<
|
||||
string,
|
||||
@@ -1051,7 +1057,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
if (!connected || !ptyId) {
|
||||
return false
|
||||
}
|
||||
return inputWriteQueue.enqueue(ptyId, data)
|
||||
return inputWriteQueue.enqueueQueryReply(ptyId, data)
|
||||
},
|
||||
|
||||
...(connectionId
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test } from './helpers/orca-app'
|
||||
import { connectDockerSshRelayTarget } from './helpers/docker-ssh-relay-connection'
|
||||
import {
|
||||
cleanupDockerSshRelayTarget,
|
||||
startDockerSshRelayTarget,
|
||||
type DockerSshRelayTarget
|
||||
} from './helpers/docker-ssh-relay-target'
|
||||
import {
|
||||
execInTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1'
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
function remoteOscQueryScript(runId: string): string {
|
||||
return [
|
||||
"process.stdin.setEncoding('utf8')",
|
||||
'if (process.stdin.isTTY) process.stdin.setRawMode(true)',
|
||||
'process.stdin.resume()',
|
||||
"let received = ''",
|
||||
`process.stdout.write('REMOTE_OSC_READY_${runId}\\n')`,
|
||||
"process.stdin.on('data', (chunk) => {",
|
||||
' received += chunk',
|
||||
" if (received.includes('\\x1b]10;rgb:')) {",
|
||||
` process.stdout.write('REMOTE_OSC_REPLY_${runId}\\n')`,
|
||||
' process.exit(0)',
|
||||
' }',
|
||||
'})',
|
||||
"setTimeout(() => process.stdout.write('\\x1b]10;?\\x1b\\\\'), 100)"
|
||||
].join(';')
|
||||
}
|
||||
|
||||
test.describe('PTY input write queue over SSH', () => {
|
||||
test.skip(!RUN_DOCKER_SSH, 'Set ORCA_E2E_SSH_DOCKER=1 to run Docker-backed SSH E2E.')
|
||||
test.skip(process.platform === 'win32', 'Docker SSH E2E uses POSIX ssh tooling.')
|
||||
|
||||
test('returns an xterm OSC query reply through the live SSH PTY', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.slow()
|
||||
let target: DockerSshRelayTarget | null = null
|
||||
try {
|
||||
target = startDockerSshRelayTarget(testInfo)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await connectDockerSshRelayTarget(orcaPage, target)
|
||||
await ensureTerminalVisible(orcaPage, 45_000)
|
||||
await waitForActiveTerminalManager(orcaPage, 60_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 60_000)
|
||||
const runId = String(Date.now())
|
||||
|
||||
await execInTerminal(orcaPage, ptyId, `node -e ${shellQuote(remoteOscQueryScript(runId))}`)
|
||||
await waitForTerminalOutput(orcaPage, `REMOTE_OSC_READY_${runId}`, 30_000, 80_000)
|
||||
await waitForTerminalOutput(orcaPage, `REMOTE_OSC_REPLY_${runId}`, 30_000, 80_000)
|
||||
} finally {
|
||||
cleanupDockerSshRelayTarget(target)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user