fix: streaming UTF-8 decoder + rAF write batching for terminal performance
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m6s
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 1m6s
Three fixes:
1. Streaming TextDecoder: a single TextDecoder instance with {stream: true}
persists across events. Split multi-byte UTF-8 sequences at Go read()
boundaries are now buffered and decoded correctly across chunks.
2. requestAnimationFrame batching: incoming SSH data is accumulated and
flushed to xterm.js once per frame instead of on every Wails event.
Eliminates the laggy typewriter effect when output arrives in small
chunks (which is normal for SSH PTY output).
3. PTY baud rate: bumped TTY_OP_ISPEED/OSPEED from 14400 (modem speed)
to 115200. Some remote PTYs throttle output to match the declared rate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9fce0b6c1e
commit
05776b7eb6
@ -91,6 +91,32 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
|
|||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
// Streaming TextDecoder persists across events so split multi-byte UTF-8
|
||||||
|
// sequences at chunk boundaries are decoded correctly (e.g. a 3-byte em-dash
|
||||||
|
// split across two Go read() calls).
|
||||||
|
const utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
||||||
|
|
||||||
|
// Write batching — accumulate chunks and flush once per animation frame.
|
||||||
|
// Without this, every tiny SSH read (sometimes single characters) triggers
|
||||||
|
// a separate terminal.write(), producing a laggy typewriter effect.
|
||||||
|
let pendingData = "";
|
||||||
|
let rafId: number | null = null;
|
||||||
|
|
||||||
|
function flushPendingData(): void {
|
||||||
|
rafId = null;
|
||||||
|
if (pendingData) {
|
||||||
|
terminal.write(pendingData);
|
||||||
|
pendingData = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueWrite(data: string): void {
|
||||||
|
pendingData += data;
|
||||||
|
if (rafId === null) {
|
||||||
|
rafId = requestAnimationFrame(flushPendingData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function mount(container: HTMLElement): void {
|
function mount(container: HTMLElement): void {
|
||||||
terminal.open(container);
|
terminal.open(container);
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
@ -115,16 +141,20 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// atob() returns Latin-1 — each byte becomes a char code 0x00–0xFF.
|
// atob() returns Latin-1 — each byte becomes a char code 0x00–0xFF.
|
||||||
// We must reconstruct the raw bytes, then decode as UTF-8.
|
// Reconstruct raw bytes, then decode with the streaming TextDecoder
|
||||||
|
// which buffers incomplete multi-byte sequences between calls.
|
||||||
const binaryStr = atob(b64data);
|
const binaryStr = atob(b64data);
|
||||||
const bytes = new Uint8Array(binaryStr.length);
|
const bytes = new Uint8Array(binaryStr.length);
|
||||||
for (let i = 0; i < binaryStr.length; i++) {
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
bytes[i] = binaryStr.charCodeAt(i);
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
}
|
}
|
||||||
terminal.write(new TextDecoder().decode(bytes));
|
const decoded = utf8Decoder.decode(bytes, { stream: true });
|
||||||
|
if (decoded) {
|
||||||
|
queueWrite(decoded);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: write raw if not valid base64
|
// Fallback: write raw if not valid base64
|
||||||
terminal.write(b64data);
|
queueWrite(b64data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -136,6 +166,15 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function destroy(): void {
|
function destroy(): void {
|
||||||
|
if (rafId !== null) {
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
rafId = null;
|
||||||
|
}
|
||||||
|
// Flush any remaining buffered data before teardown
|
||||||
|
if (pendingData) {
|
||||||
|
terminal.write(pendingData);
|
||||||
|
pendingData = "";
|
||||||
|
}
|
||||||
if (cleanupEvent) {
|
if (cleanupEvent) {
|
||||||
cleanupEvent();
|
cleanupEvent();
|
||||||
cleanupEvent = null;
|
cleanupEvent = null;
|
||||||
|
|||||||
@ -72,8 +72,8 @@ func (s *SSHService) Connect(hostname string, port int, username string, authMet
|
|||||||
|
|
||||||
modes := ssh.TerminalModes{
|
modes := ssh.TerminalModes{
|
||||||
ssh.ECHO: 1,
|
ssh.ECHO: 1,
|
||||||
ssh.TTY_OP_ISPEED: 14400,
|
ssh.TTY_OP_ISPEED: 115200,
|
||||||
ssh.TTY_OP_OSPEED: 14400,
|
ssh.TTY_OP_OSPEED: 115200,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
if err := session.RequestPty("xterm-256color", rows, cols, modes); err != nil {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user