Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m25s
Build App (Preview) / build-windows (pull_request) Successful in 5m32s
Build App (Preview) / prune-previews (pull_request) Successful in 8s
Two separate defects behind the same report: typing in a container terminal
is sluggish on Linux, and a backspace can land *after* the characters typed
behind it.
The web terminal was the control that separated them. It shares the Docker
exec, the PTY, `exec_manager`, the input channel and its serial writer task,
and xterm.js itself — and it does not exhibit either symptom. Only three
things differ, and each accounts for part of the report.
**Input ordering.** Every keystroke was its own `invoke("terminal_input")`.
That command is `async`, so Tauri spawns each one as an independent task, and
those tasks then race for the session mutex in `ExecSessionManager::send_input`
— nothing preserved the order the bytes were typed in. The serial writer
downstream cannot help, because the order is already lost before anything
reaches the channel. The web terminal gets ordering for free by awaiting
`send_input` inline in a single WebSocket reader loop.
`useTerminal` now holds a per-session queue: one write in flight at a time,
the next only after the previous resolves. Anything typed meanwhile coalesces
into the next chunk, which also collapses a burst of typing into a couple of
IPC round trips rather than one per key. The queue is module scope, not hook
scope, because `useTerminal()` is called from several components — a per-hook
queue would leave speech-to-text, image paste and typing racing each other.
Each caller's promise still settles only when its own bytes have gone, so
`await sendInput(...)` keeps its meaning.
**The DMA-BUF escape hatch did not exist.** `apply_webkit_wayland_workaround`
left any pre-set value alone, including `0`, on a stated assumption that
WebKitGTK reads the variable as a boolean. It reads presence, so
`WEBKIT_DISABLE_DMABUF_RENDERER=0` disabled DMA-BUF exactly like `=1`, and no
value a user could set got the accelerated path back. `0`/`false`/`no`/empty
now remove the variable, which is the only thing WebKitGTK reads as enabled.
The default is unchanged: unset still means disabled on Linux.
**WebGL does not degrade to canvas here.** The comment on that workaround
assumed `@xterm/addon-webgl` would fall back to the canvas renderer once
DMA-BUF was off. Its constructor throws only when WebGL is *absent*, and with
DMA-BUF disabled WebGL is still present — served by software rasterisation.
So the addon loads and every frame is rendered on the CPU, slower than the
canvas renderer it was assumed to fall back to. `AppSettings::terminal_gpu_
rendering` decides whether it loads at all: `None` is auto (on for macOS and
Windows, off on Linux), `Some(_)` forces it either way from Settings →
Terminal. `Option<bool>` rather than `bool` so the zero value means "we
choose" instead of pinning every existing settings file to one answer.
Verified: 643 frontend tests and 530 Rust tests pass, clippy clean, secret
scan clean. The Linux rendering half needs confirming on a real desktop —
neither symptom reproduces in a headless container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
191 lines
6.6 KiB
TypeScript
191 lines
6.6 KiB
TypeScript
import { useCallback } from "react";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { listen } from "@tauri-apps/api/event";
|
|
import { useAppState } from "../store/appState";
|
|
import * as commands from "../lib/tauri-commands";
|
|
|
|
/**
|
|
* Per-session ordered write queue.
|
|
*
|
|
* Every keystroke used to be its own `invoke("terminal_input")`, and because
|
|
* that command is `async` on the Rust side Tauri spawns each one as an
|
|
* independent task. Those tasks then race for the session mutex in
|
|
* `ExecSessionManager::send_input`, so nothing preserved the order the bytes
|
|
* were typed in — the visible symptom was a backspace landing *after* the
|
|
* characters typed behind it. The serial writer task downstream cannot help,
|
|
* because the order is already lost by the time anything reaches the channel.
|
|
*
|
|
* The queue restores ordering the same way the web terminal gets it for free:
|
|
* one write in flight at a time, the next only after the previous resolves.
|
|
* Anything typed while a write is in flight coalesces into the next chunk,
|
|
* which also collapses a burst of typing into a couple of IPC round trips
|
|
* rather than one per key. Concatenating the byte arrays is safe — a PTY
|
|
* cannot tell one write of "ab" from writes of "a" then "b" — and each
|
|
* caller's promise still settles only when its own bytes have gone, so
|
|
* `await sendInput(...)` keeps the meaning it had.
|
|
*
|
|
* Module scope, not hook scope, because `useTerminal()` is called from several
|
|
* components (App for speech-to-text, TerminalView for typing and image paste,
|
|
* useProjectActions for tile commands). A per-hook queue would give each caller
|
|
* its own ordering and leave them racing against each other.
|
|
*/
|
|
type PendingWrite = {
|
|
bytes: number[];
|
|
resolve: () => void;
|
|
reject: (reason: unknown) => void;
|
|
};
|
|
|
|
const inputQueues = new Map<string, { pending: PendingWrite[]; draining: boolean }>();
|
|
|
|
async function drainInputQueue(sessionId: string): Promise<void> {
|
|
const q = inputQueues.get(sessionId);
|
|
if (!q || q.draining) return;
|
|
|
|
q.draining = true;
|
|
try {
|
|
while (q.pending.length > 0) {
|
|
// Take everything queued so far as one batch, preserving order.
|
|
const batch = q.pending.splice(0, q.pending.length);
|
|
const bytes = batch.flatMap((w) => w.bytes);
|
|
try {
|
|
await commands.terminalInput(sessionId, bytes);
|
|
batch.forEach((w) => w.resolve());
|
|
} catch (err) {
|
|
// Reject only the writes in this batch. Anything queued while it was
|
|
// in flight is still pending and gets its own attempt on the next lap.
|
|
batch.forEach((w) => w.reject(err));
|
|
}
|
|
}
|
|
} finally {
|
|
q.draining = false;
|
|
// Drop the entry once idle so closed sessions do not accumulate.
|
|
if (q.pending.length === 0) inputQueues.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
function enqueueInput(sessionId: string, bytes: number[]): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
let q = inputQueues.get(sessionId);
|
|
if (!q) {
|
|
q = { pending: [], draining: false };
|
|
inputQueues.set(sessionId, q);
|
|
}
|
|
q.pending.push({ bytes, resolve, reject });
|
|
void drainInputQueue(sessionId);
|
|
});
|
|
}
|
|
|
|
/** Drop any queued input for a session that is going away. */
|
|
function discardInputQueue(sessionId: string): void {
|
|
const q = inputQueues.get(sessionId);
|
|
if (!q) return;
|
|
const dropped = q.pending.splice(0, q.pending.length);
|
|
dropped.forEach((w) => w.reject(new Error(`Session ${sessionId} closed`)));
|
|
if (!q.draining) inputQueues.delete(sessionId);
|
|
}
|
|
|
|
export function useTerminal() {
|
|
const { sessions, activeSessionId, addSession, removeSession, setActiveSession } =
|
|
useAppState(
|
|
useShallow(s => ({
|
|
sessions: s.sessions,
|
|
activeSessionId: s.activeSessionId,
|
|
addSession: s.addSession,
|
|
removeSession: s.removeSession,
|
|
setActiveSession: s.setActiveSession,
|
|
}))
|
|
);
|
|
|
|
const open = useCallback(
|
|
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
|
|
const sessionId = crypto.randomUUID();
|
|
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
|
|
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
|
|
return sessionId;
|
|
},
|
|
[addSession],
|
|
);
|
|
|
|
const close = useCallback(
|
|
async (sessionId: string) => {
|
|
// Capture session/project info before we drop it from local state.
|
|
const { sessions: currentSessions, projects } = useAppState.getState();
|
|
const session = currentSessions.find((s) => s.id === sessionId);
|
|
const project = session ? projects.find((p) => p.id === session.projectId) : undefined;
|
|
|
|
discardInputQueue(sessionId);
|
|
await commands.closeTerminalSession(sessionId);
|
|
removeSession(sessionId);
|
|
|
|
// Drop any persisted custom name for this session.
|
|
if (project && project.renamed_session_names && sessionId in project.renamed_session_names) {
|
|
const map = { ...project.renamed_session_names };
|
|
delete map[sessionId];
|
|
try {
|
|
const updated = await commands.updateProject({ ...project, renamed_session_names: map });
|
|
useAppState.getState().updateProjectInList(updated);
|
|
} catch (err) {
|
|
console.error("Failed to clear renamed tab name on close:", err);
|
|
}
|
|
}
|
|
},
|
|
[removeSession],
|
|
);
|
|
|
|
const sendInput = useCallback(
|
|
async (sessionId: string, data: string) => {
|
|
const bytes = Array.from(new TextEncoder().encode(data));
|
|
await enqueueInput(sessionId, bytes);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const resize = useCallback(
|
|
async (sessionId: string, cols: number, rows: number) => {
|
|
await commands.terminalResize(sessionId, cols, rows);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const pasteImage = useCallback(
|
|
async (sessionId: string, imageData: Uint8Array) => {
|
|
const bytes = Array.from(imageData);
|
|
return commands.pasteImageToTerminal(sessionId, bytes);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const onOutput = useCallback(
|
|
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
|
const eventName = `terminal-output-${sessionId}`;
|
|
return listen<number[]>(eventName, (event) => {
|
|
callback(new Uint8Array(event.payload));
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const onExit = useCallback(
|
|
(sessionId: string, callback: () => void) => {
|
|
const eventName = `terminal-exit-${sessionId}`;
|
|
return listen<void>(eventName, () => {
|
|
callback();
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
return {
|
|
sessions,
|
|
activeSessionId,
|
|
setActiveSession,
|
|
open,
|
|
close,
|
|
sendInput,
|
|
pasteImage,
|
|
resize,
|
|
onOutput,
|
|
onExit,
|
|
};
|
|
}
|