Merge remote-tracking branch 'origin/main' into fix-abort-error-flash

This commit is contained in:
marius-kilocode
2026-08-25 14:43:36 +02:00
13 changed files with 227 additions and 102 deletions
@@ -2,4 +2,4 @@
"kilo-code": patch
---
Render Agent Manager terminal output with the WebGL renderer instead of the DOM renderer, and pause rendering for terminals hidden in the background
Use the DOM renderer for Agent Manager terminals to avoid WebGL context failures, while batching output and pausing hidden-terminal rendering
-3
View File
@@ -461,7 +461,6 @@
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode-graphemes": "0.4.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"diff": "8.0.4",
"fastest-levenshtein": "^1.0.16",
@@ -2795,8 +2794,6 @@
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
"@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="],
-1
View File
@@ -1327,7 +1327,6 @@
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode-graphemes": "0.4.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"diff": "8.0.4",
"fastest-levenshtein": "^1.0.16",
@@ -1,7 +1,12 @@
import { describe, expect, it } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { LOCAL } from "../../webview-ui/agent-manager/navigate"
import { ambientDecision, createAmbientSetup, showTerminalStack } from "../../webview-ui/agent-manager/terminal/ambient"
import {
ambientDecision,
createAmbientSetup,
keepTerminalStack,
showTerminalStack,
} from "../../webview-ui/agent-manager/terminal/ambient"
import { createTerminalState } from "../../webview-ui/agent-manager/terminal/state"
describe("showTerminalStack", () => {
@@ -33,6 +38,14 @@ describe("showTerminalStack", () => {
})
})
describe("keepTerminalStack", () => {
it("keeps live terminals mounted under history", () => {
expect(keepTerminalStack(true, "wt-1", false, 1)).toBe(true)
expect(keepTerminalStack(true, null, true, 1)).toBe(true)
expect(keepTerminalStack(true, "wt-1", false, 0)).toBe(false)
})
})
describe("ambientDecision", () => {
it("waits while setup is still running", () => {
expect(ambientDecision(undefined, "wt-1", "wt-1")).toBe("wait")
@@ -15,6 +15,7 @@ const terminal = readFileSync(
resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"),
"utf8",
)
const pkg = readFileSync(resolve(import.meta.dir, "../../package.json"), "utf8")
test("xterm owns the padding used by FitAddon", () => {
const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1]
@@ -74,6 +75,16 @@ test("does not refit hidden terminal buffers during resize", () => {
expect(callback!.indexOf("if (!props.active) return")).toBeLessThan(callback!.indexOf("fit.fit()"))
})
test("uses the scalable DOM renderer for concurrent terminals", () => {
expect(terminal).not.toContain("WebglAddon")
expect(pkg).not.toContain("@xterm/addon-webgl")
})
test("orders local terminal status lines through the output batcher", () => {
expect(terminal).toContain("const writeLine =")
expect(terminal).not.toContain("term.writeln(")
})
test("keeps raw PTY line endings and initializes Unicode widths before attaching", () => {
expect(terminal).toContain("convertEol: false")
expect(terminal).toContain('term.unicode.activeVersion = "15-graphemes"')
@@ -85,7 +96,17 @@ test("keeps raw PTY line endings and initializes Unicode widths before attaching
test("fits and forces the initial PTY dimensions before socket attach", () => {
expect(terminal).toContain("const syncSize = (force = false)")
expect(terminal).toContain("if (props.active) syncSize(true)")
expect(terminal.indexOf("fitNow()\n open(props.wsUrl)")).toBeGreaterThan(-1)
expect(terminal.indexOf("fitNow()\n if (!ws) open(props.wsUrl)")).toBeGreaterThan(-1)
})
test("keeps terminal sockets mounted while history is open", () => {
expect(css).toContain(".am-detail-stack-hidden")
expect(css).toMatch(/\.am-detail-stack-hidden[^}]*top: 36px/s)
expect(css).toMatch(/\.am-detail-stack-hidden[^}]*transform: translate\(-100vw, 0\)/s)
})
test("moves a closed side panel outside xterm's intersection area", () => {
expect(css).toMatch(/\.am-side-host-hidden[^}]*transform: translate\(-100vw, 0\)/s)
})
test("re-sends dimensions when an optimistic terminal receives its PTY", () => {
@@ -51,6 +51,14 @@ describe("Agent Manager terminal write batcher", () => {
expect(h.writes).toEqual(["abc"])
})
it("keeps local status output after pending PTY output", () => {
const h = harness()
h.batcher.write("last output")
h.batcher.write("\r\n[terminal ended]\r\n")
h.run()
expect(h.writes).toEqual(["last output\r\n[terminal ended]\r\n"])
})
it("coalesces many frames into separate writes", () => {
const h = harness()
h.batcher.write("1")
@@ -65,9 +73,9 @@ describe("Agent Manager terminal write batcher", () => {
h.batcher.write("txt")
h.batcher.write(new Uint8Array([1, 2]))
h.run()
expect(h.writes).toHaveLength(1)
const merged = h.writes[0] as Uint8Array
expect(Array.from(merged)).toEqual([116, 120, 116, 1, 2])
expect(h.writes).toHaveLength(2)
expect(h.writes[0]).toBe("txt")
expect(Array.from(h.writes[1] as Uint8Array)).toEqual([1, 2])
})
it("fires chunk callbacks after the batch write completes", () => {
@@ -138,6 +146,22 @@ describe("Agent Manager terminal input buffer", () => {
expect(input.take()).toBe("bcde2345")
})
it("clears buffered input after a failed replay", () => {
const input = createInputBuffer()
input.add("command\r")
input.add("reply", true)
input.clear()
expect(input.take()).toBe("")
})
it("does not flush input when replay exceeds its limit", () => {
let flushed = 0
const gate = createReplayGate({ write: () => undefined, flush: () => flushed++ })
gate.attach(false)
expect(gate.output("x".repeat(8 * 1024 * 1024 + 1))).toBe(false)
expect(flushed).toBe(0)
})
})
describe("Agent Manager terminal replay gate", () => {
@@ -154,7 +154,7 @@ import {
createSideTerminal,
createAmbientSetup,
hasSetupTerminal,
showTerminalStack,
keepTerminalStack,
readSavedDestination,
resolveRunScriptRequest,
resolveVscodeTerminalRequest,
@@ -827,7 +827,9 @@ const AgentManagerContent: Component = () => {
return false
})
const showDetailStack = createMemo(() => showTerminalStack(history(), selection(), contextEmpty()))
const showDetailStack = createMemo(() =>
keepTerminalStack(history(), selection(), contextEmpty(), terms.all().length + terms.sides().length),
)
const overlay = createMemo((): SetupState | null => {
const state = setup()
@@ -2503,7 +2505,7 @@ const AgentManagerContent: Component = () => {
</Show>
<Show when={showDetailStack()}>
{/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */}
<div class="am-detail-stack">
<div class={`am-detail-stack ${history() ? "am-detail-stack-hidden" : ""}`} inert={history()}>
{/* Chat/terminal + side diff panel. Keep it mounted under the
review tab so live xterm canvases never leave the paint tree. */}
<div
@@ -4773,7 +4773,7 @@ body.vscode-high-contrast-light {
* Terminals are never unmounted once mounted; inactive slots hide one
* viewport to the left while keeping their layout box. xterm 6's render
* service observes the screen element and pauses hidden terminals'
* render loops (rAF, model updates, GPU draws), then replays a full
* render loops (rAF and model updates), then replays a full
* refresh when a slot becomes visible again — the activation fit +
* refresh in the TerminalTab component is insurance on top of that. The
* historical `display: none` avoidance was for the "press Enter to see
@@ -4801,6 +4801,17 @@ body.vscode-high-contrast-light {
flex-direction: column;
}
.am-detail-stack-hidden {
position: absolute;
top: 36px;
right: 0;
bottom: 0;
left: 0;
opacity: 0;
pointer-events: none;
transform: translate(-100vw, 0);
}
.am-terminal-layer {
position: absolute;
inset: 0;
@@ -4821,7 +4832,7 @@ body.vscode-high-contrast-light {
.am-terminal-slot {
/* Hidden slots stay in layout but are translated one viewport to the
left, so xterm's render observer sees no intersection and pauses the
render loop (rAF loop, model updates, WebGL draws), then replays a
render loop (rAF loop and model updates), then replays a
full refresh when the slot slides back in. Keeping the layout box
(unlike display:none) lets FitAddon measure the real panel size
while hidden — background-created terminals such as setup scripts
@@ -4975,6 +4986,7 @@ body.vscode-high-contrast-light {
bottom: 0;
opacity: 0;
pointer-events: none;
transform: translate(-100vw, 0);
}
/* Subagent inspector panel. It remains mounted while another inspector mode
@@ -13,7 +13,6 @@
import { Component, createEffect, onCleanup, onMount } from "solid-js"
import { Terminal } from "@xterm/xterm"
import { FitAddon } from "@xterm/addon-fit"
import { WebglAddon } from "@xterm/addon-webgl"
import { WebLinksAddon } from "@xterm/addon-web-links"
import { ClipboardAddon } from "@xterm/addon-clipboard"
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes"
@@ -168,28 +167,12 @@ export const TerminalTab: Component<Props> = (props) => {
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
// GPU renderer: the DOM renderer creates one element per cell per row,
// which dominates CPU under sustained PTY output (see profiling of
// streaming script output). WebGL paints the whole viewport in one
// draw batched by first glyph. Falls back to the DOM renderer when
// WebGL2 is unavailable; the catch logs without breaking the session.
try {
const webgl = new WebglAddon()
term.loadAddon(webgl)
// Browsers hand out a limited number of WebGL contexts and evict
// old ones; when a context is lost beyond recovery, dispose the
// addon so xterm re-installs its DOM renderer for this terminal
// instead of leaving a dead canvas.
webgl.onContextLoss(() => {
try {
webgl.dispose()
} catch (err) {
log("webgl dispose after context loss failed", err)
}
})
} catch (err) {
log("webgl renderer unavailable, using DOM renderer", err)
}
// Keep xterm's DOM renderer. Every mounted WebGL addon owns a scarce
// browser context, including when xterm pauses an off-screen terminal.
// Chromium can evict a live renderer once enough Agent Manager terminals
// exist, and xterm 6.0's WebGL addon also has unresolved shared-atlas
// corruption. Frame batching below removes the per-message render churn
// without making terminal correctness depend on GPU resources.
registerTerminalOutput(props.terminalId, () => {
const buffer = term.buffer.active
return Array.from(
@@ -243,6 +226,8 @@ export const TerminalTab: Component<Props> = (props) => {
let socketEnded = false
let frame: number | undefined
let deferred: number | undefined
const batcher = createWriteBatcher((data, callback) => term.write(data, callback))
const writeLine = (data: string) => batcher.write(`${data}\r\n`)
// The failure line must not depend on event ordering: the stream can
// close before the exited snapshot lands (fast failures), or stay open
// when a background child outlives the script. Write it exactly once,
@@ -254,12 +239,12 @@ export const TerminalTab: Component<Props> = (props) => {
if (status?.kind !== "setup") return
if (status.state === "failed") {
failureWritten = true
term.writeln(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailed")}]\x1b[0m`)
writeLine(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailed")}]\x1b[0m`)
return
}
if (status.state === "exited" && status.exitCode !== 0) {
failureWritten = true
term.writeln(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailedCode")} ${status.exitCode ?? "?"}]\x1b[0m`)
writeLine(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailedCode")} ${status.exitCode ?? "?"}]\x1b[0m`)
}
}
createEffect(() => {
@@ -318,7 +303,6 @@ export const TerminalTab: Component<Props> = (props) => {
flush()
}, 100)
}
const batcher = createWriteBatcher((data, callback) => term.write(data, callback))
const replay = createReplayGate({
write: (data, callback) => batcher.write(data, callback),
flush: () => flush(true),
@@ -329,6 +313,7 @@ export const TerminalTab: Component<Props> = (props) => {
}
const open = (url: string) => {
if (closed || !url) return
if (ws && ws.readyState !== WebSocket.CLOSING && ws.readyState !== WebSocket.CLOSED) return
replay.attach(disconnected)
const next = new WebSocket(url)
next.binaryType = "arraybuffer"
@@ -347,20 +332,28 @@ export const TerminalTab: Component<Props> = (props) => {
if (closed || ws !== next) return
streamed = true
if (typeof event.data === "string") {
replay.output(event.data)
if (!replay.output(event.data)) {
input.clear()
next.close(1009, "terminal replay exceeded limit")
return
}
scheduleFlush()
return
}
if (event.data instanceof ArrayBuffer) {
const bytes = new Uint8Array(event.data)
if (replay.frame(bytes)) return
replay.output(bytes)
if (!replay.output(bytes)) {
input.clear()
next.close(1009, "terminal replay exceeded limit")
return
}
scheduleFlush()
}
}
next.onerror = () => {
if (closed || ws !== next) return
term.writeln(`\r\n\x1b[90m[${t("agentManager.terminal.connectionError")}]\x1b[0m`)
writeLine(`\r\n\x1b[90m[${t("agentManager.terminal.connectionError")}]\x1b[0m`)
}
next.onclose = () => {
if (closed || ws !== next) return
@@ -380,7 +373,7 @@ export const TerminalTab: Component<Props> = (props) => {
restartRequested = false
}
const key = props.restartable ? "agentManager.terminal.endedRestartable" : "agentManager.terminal.ended"
term.writeln(`\r\n\x1b[90m[${t(key)}]\x1b[0m`)
writeLine(`\r\n\x1b[90m[${t(key)}]\x1b[0m`)
}
}
const disposeData = term.onData(send)
@@ -448,8 +441,6 @@ export const TerminalTab: Component<Props> = (props) => {
return
}
clearTimeout(resizeTimer)
if (readyTimer) clearTimeout(readyTimer)
if (fallbackTimer) clearTimeout(fallbackTimer)
resizeTimer = setTimeout(syncSize, RESIZE_DEBOUNCE_MS)
})
ro.observe(host)
@@ -460,7 +451,7 @@ export const TerminalTab: Component<Props> = (props) => {
frame = undefined
if (closed) return
fitNow()
open(props.wsUrl)
if (!ws) open(props.wsUrl)
deferred = requestAnimationFrame(loadAddons)
})
@@ -480,6 +471,7 @@ export const TerminalTab: Component<Props> = (props) => {
// resizes or font changes must not yank the cursor out of the chat
// input, only explicit activation / focus requests may.
let pendingFrame: number | null = null
let repaintTimer: ReturnType<typeof setTimeout> | undefined
let shouldFocus = false
const isRenderable = () => {
if (!host.isConnected) return false
@@ -488,6 +480,8 @@ export const TerminalTab: Component<Props> = (props) => {
}
const runRepaint = () => {
pendingFrame = null
clearTimeout(repaintTimer)
repaintTimer = undefined
if (!props.active) return
if (!isRenderable()) return
try {
@@ -505,6 +499,11 @@ export const TerminalTab: Component<Props> = (props) => {
shouldFocus ||= focus
if (pendingFrame !== null) return
pendingFrame = requestAnimationFrame(runRepaint)
repaintTimer = setTimeout(() => {
if (pendingFrame === null) return
cancelAnimationFrame(pendingFrame)
runRepaint()
}, 250)
}
const fontSub = vscode.onMessage((message) => {
if (message.type === "appendReviewCommentsToTerminal") {
@@ -587,12 +586,12 @@ export const TerminalTab: Component<Props> = (props) => {
const ownsFocus = () => host.contains(document.activeElement)
const onVisibilityChange = () => {
if (document.hidden) return
if (!props.active || !ownsFocus()) return
scheduleRepaint(true)
if (!props.active) return
scheduleRepaint(ownsFocus())
}
const onWindowFocus = () => {
if (!props.active || !ownsFocus()) return
scheduleRepaint(true)
if (!props.active) return
scheduleRepaint(ownsFocus())
}
document.addEventListener("visibilitychange", onVisibilityChange)
window.addEventListener("focus", onWindowFocus)
@@ -613,8 +612,10 @@ export const TerminalTab: Component<Props> = (props) => {
onCleanup(() => {
closed = true
batcher.cancel()
replay.cancel()
unregisterTerminalOutput(props.terminalId)
if (pendingFrame !== null) cancelAnimationFrame(pendingFrame)
clearTimeout(repaintTimer)
if (frame !== undefined) cancelAnimationFrame(frame)
if (deferred !== undefined) cancelAnimationFrame(deferred)
document.removeEventListener("visibilitychange", onVisibilityChange)
@@ -628,6 +629,8 @@ export const TerminalTab: Component<Props> = (props) => {
fontSub()
themeObserver.disconnect()
clearTimeout(resizeTimer)
clearTimeout(readyTimer)
clearTimeout(fallbackTimer)
ro.disconnect()
disposeData.dispose()
disposeBinary.dispose()
@@ -27,11 +27,21 @@ export type AmbientDecision = "wait" | "hide" | "keep"
/** The detail stack hosts chat, terminals, and the read-only banner.
* It shows for any selected context (local/worktree) and for an
* unassigned session, where `selection` is null but the context is not
* empty (a live session is showing). The history view is exclusive. */
* empty (a live session is showing). Keep it mounted under history when
* terminals exist; the caller hides it without disposing xterm sockets. */
export function showTerminalStack(history: boolean, selection: string | null, contextEmpty: boolean): boolean {
return !history && (selection !== null || !contextEmpty)
}
export function keepTerminalStack(
history: boolean,
selection: string | null,
contextEmpty: boolean,
terminals: number,
): boolean {
return showTerminalStack(history, selection, contextEmpty) || terminals > 0
}
/** Setup output owns progress/error presentation when its terminal exists. */
export function hasSetupTerminal(selection: string | null, terminals: TerminalTabStateWithContext[]): boolean {
return selection !== null && terminals.some((term) => term.contextKey === selection && term.kind === "setup")
@@ -26,4 +26,4 @@ export { TerminalDestinationButton } from "./TerminalDestinationButton"
export { createSideTerminal, readSavedDestination, resolveRunScriptRequest, resolveVscodeTerminalRequest } from "./side"
export { TerminalTab } from "./TerminalTab"
export { SortableTerminalTab } from "./SortableTerminalTab"
export { createAmbientSetup, hasSetupTerminal, showTerminalStack } from "./ambient"
export { createAmbientSetup, hasSetupTerminal, keepTerminalStack, showTerminalStack } from "./ambient"
@@ -73,8 +73,8 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
* **Once an xterm instance is mounted, its DOM subtree is only ever
* hidden, never unmounted.** Inactive slots keep their layout box but
* are translated one viewport off-screen; xterm 6's render service
* observes the screen element and pauses the render loop (rAF, model
* updates, GPU draws) for non-intersecting terminals, then replays a
* observes the screen element and pauses the render loop (rAF and model
* updates) for non-intersecting terminals, then replays a
* full refresh when the slot slides back in. Keeping the box (unlike
* `display: none`) also lets FitAddon measure the real panel size while
* hidden, so background-created terminals such as setup scripts wrap
@@ -5,6 +5,21 @@ interface ReplayGateDeps {
flush(): void
}
const encoder = new TextEncoder()
const decoder = new TextDecoder()
export function byteLength(data: string | Uint8Array) {
return typeof data === "string" ? encoder.encode(data).byteLength : data.byteLength
}
function tail(data: string, limit: number) {
const bytes = encoder.encode(data)
if (bytes.byteLength <= limit) return data
let start = bytes.byteLength - limit
while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) start++
return decoder.decode(bytes.subarray(start))
}
/** Keep terminal protocol replies ahead of user input without reordering the
* user's bytes when both arrive while initial replay is being parsed. */
export function createInputBuffer(limit = 256 * 1024) {
@@ -14,11 +29,11 @@ export function createInputBuffer(limit = 256 * 1024) {
const add = (data: string, reply = false) => {
if (reply) {
replies += data
if (replies.length > limit) replies = replies.slice(-limit)
replies = tail(replies, limit)
return
}
input += data
if (input.length > limit) input = input.slice(-limit)
input = tail(input, limit)
}
const take = () => {
@@ -28,7 +43,12 @@ export function createInputBuffer(limit = 256 * 1024) {
return data
}
return { add, take }
const clear = () => {
replies = ""
input = ""
}
return { add, clear, take }
}
/**
@@ -59,49 +79,45 @@ export function createWriteBatcher(
let callbacks: Array<() => void> = []
let pendingBytes = 0
let scheduled = false
let raf = 0
let watchdog: ReturnType<typeof setTimeout> | 0 = 0
let raf: number | undefined
let watchdog: ReturnType<typeof setTimeout> | undefined
const drain = () => {
if (raf !== 0) unschedule(raf)
if (watchdog !== 0) clearDelay(watchdog)
raf = 0
watchdog = 0
if (raf !== undefined) unschedule(raf)
if (watchdog !== undefined) clearDelay(watchdog)
raf = undefined
watchdog = undefined
scheduled = false
const data = chunks
const cbs = callbacks
chunks = []
callbacks = []
pendingBytes = 0
let joined: string | Uint8Array = ""
if (data.length === 1) {
joined = data[0]!
} else {
let text = true
for (const chunk of data) {
if (typeof chunk !== "string") {
text = false
break
}
if (data.length === 0 && cbs.length === 0) return
const groups: Array<string | Uint8Array> = []
for (const chunk of data) {
const prior = groups.at(-1)
if (typeof chunk === "string") {
if (typeof prior === "string") groups[groups.length - 1] = prior + chunk
else groups.push(chunk)
continue
}
if (text) {
joined = data.join("")
} else {
const encoder = new TextEncoder()
const parts = data.map((chunk) => (typeof chunk === "string" ? encoder.encode(chunk) : chunk))
const length = parts.reduce((sum, part) => sum + part.byteLength, 0)
const merged = new Uint8Array(length)
let offset = 0
for (const part of parts) {
merged.set(part, offset)
offset += part.byteLength
}
joined = merged
if (!(prior instanceof Uint8Array)) {
groups.push(chunk)
continue
}
const merged = new Uint8Array(prior.byteLength + chunk.byteLength)
merged.set(prior)
merged.set(chunk, prior.byteLength)
groups[groups.length - 1] = merged
}
write(joined, () => {
if (groups.length === 0) groups.push("")
const complete = () => {
for (const cb of cbs) cb()
})
}
for (let index = 0; index < groups.length; index++) {
write(groups[index]!, index === groups.length - 1 ? complete : undefined)
}
}
const kick = () => {
@@ -112,8 +128,11 @@ export function createWriteBatcher(
}
const writeChunk = (data: string | Uint8Array, callback?: () => void) => {
if ((typeof data === "string" ? data.length : data.byteLength) === 0 && !callback) return
const bytes = byteLength(data)
if (pendingBytes > 0 && pendingBytes + bytes > maxBytes) drain()
chunks.push(data)
pendingBytes += typeof data === "string" ? data.length : data.byteLength
pendingBytes += bytes
if (callback) callbacks.push(callback)
if (pendingBytes >= maxBytes) {
drain()
@@ -123,10 +142,10 @@ export function createWriteBatcher(
}
const cancel = () => {
if (raf !== 0) unschedule(raf)
if (watchdog !== 0) clearDelay(watchdog)
raf = 0
watchdog = 0
if (raf !== undefined) unschedule(raf)
if (watchdog !== undefined) clearDelay(watchdog)
raf = undefined
watchdog = undefined
scheduled = false
chunks = []
callbacks = []
@@ -150,7 +169,12 @@ export function createReplayGate(deps: ReplayGateDeps) {
let boundary = false
let draining = false
let serial = 0
let pending: Array<string | Uint8Array> = []
let pending: Array<{ data: string | Uint8Array; callback?: () => void }> = []
let bytes = 0
// The server retains at most 2 Mi UTF-16 code units. Eight MiB covers
// their maximum UTF-8 expansion while still staying far below xterm's
// 50 MiB discard watermark if a boundary frame never arrives.
const limit = 8 * 1024 * 1024
const attach = (reconnecting: boolean) => {
serial++
@@ -158,14 +182,24 @@ export function createReplayGate(deps: ReplayGateDeps) {
boundary = false
draining = false
pending = []
bytes = 0
}
const output = (data: string | Uint8Array) => {
const output = (data: string | Uint8Array, callback?: () => void) => {
if (blocked && !boundary) {
pending.push(data)
return
bytes += byteLength(data)
if (bytes > limit) {
serial++
blocked = false
pending = []
bytes = 0
return false
}
pending.push({ data, callback })
return true
}
deps.write(data)
deps.write(data, callback)
return true
}
const frame = (data: Uint8Array) => {
@@ -179,8 +213,9 @@ export function createReplayGate(deps: ReplayGateDeps) {
// parser-generated replies in its separate priority buffer meanwhile.
draining = true
const current = serial
for (const chunk of pending) deps.write(chunk)
for (const chunk of pending) deps.write(chunk.data, chunk.callback)
pending = []
bytes = 0
deps.write("", () => {
if (serial !== current) return
draining = false
@@ -191,5 +226,14 @@ export function createReplayGate(deps: ReplayGateDeps) {
return true
}
return { attach, blocked: () => blocked, draining: () => draining, frame, output }
const cancel = () => {
serial++
blocked = false
boundary = false
draining = false
pending = []
bytes = 0
}
return { attach, blocked: () => blocked, cancel, draining: () => draining, frame, output }
}