tiletopia/src/components/XtermPane.tsx
megaproxy 309b6024d4 Fix XtermPane IPC listener leak on unmount-during-spawn/adopt
Pre-release audit finding: after `unlistenData = await onPaneData(...)` (and
the exit listener) there was no destroyed re-check, so if the pane unmounted
during the await the sync cleanup captured a null unlisten and the
pane://{id}/data subscription leaked. Unlisten before returning in both the
adopt and spawn paths.

Also logs the deferred (low-risk) transfer-refcount leak as a known follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:34:36 +01:00

421 lines
16 KiB
TypeScript

import { useRef, useEffect } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import type { UnlistenFn } from "@tauri-apps/api/event";
import {
readText as clipboardReadText,
writeText as clipboardWriteText,
} from "@tauri-apps/plugin-clipboard-manager";
import { openUrl } from "@tauri-apps/plugin-opener";
import {
spawnPane,
writeToPane,
resizePane,
killPane,
onPaneData,
onPaneExit,
getPaneRing,
claimPane,
type PaneId,
type SpawnSpec,
} from "../ipc";
// ---------------------------------------------------------------------------
// base64 helpers (private to this module)
// ---------------------------------------------------------------------------
function b64ToBytes(b64: string): Uint8Array {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64(bytes: Uint8Array): string {
let s = "";
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}
function stringToB64(s: string): string {
// xterm.js's onData emits a JS string; UTF-8 encode before base64.
return bytesToB64(new TextEncoder().encode(s));
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface XtermPaneProps {
/** Spec describing what to spawn into this pane's PTY. Read once at mount;
* changing it later does NOT respawn — callers force a respawn by
* changing the React `key` (see Pane.svelte / LeafPane). */
spec: SpawnSpec;
/** Attach to an existing PTY (transferred from another window) instead of
* spawning a new one. When set: spec is ignored at the spawn step, the
* scrollback ring is replayed into xterm.js, the live data listener is
* attached, and the transfer refcount is claimed (decremented) so the
* source window's killPane is no longer suppressed. */
existingPaneId?: PaneId;
onStatus?: (msg: string, ok: boolean) => void;
/** Fired once when the backend PTY is alive and we have its PaneId. */
onSpawn?: (paneId: PaneId) => void;
/** Fired AFTER each writeToPane on user keypress. Used by broadcasting. */
onInput?: (dataB64: string) => void;
/** Fired whenever output arrives from the PTY. Used for idle detection. */
onDataReceived?: () => void;
/** Fired when xterm's textarea gains focus (i.e., user clicked here). */
onFocus?: () => void;
/** Increment to refocus the terminal programmatically (palette etc.). */
focusTrigger?: number;
/** Absolute font size in px. Changes are applied live (fit + PTY resize). */
fontSize?: number;
}
const DEFAULT_XTERM_FONT_SIZE = 13;
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function XtermPane({
spec,
existingPaneId,
onStatus,
onSpawn,
onInput,
onDataReceived,
onFocus,
focusTrigger = 0,
fontSize,
}: XtermPaneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const paneIdRef = useRef<PaneId | null>(null);
// Stash the most recent `fontSize` prop so the mount effect can pick
// up the initial value without re-running when it changes (the secondary
// effect below handles dynamic updates).
const initialFontSizeRef = useRef(fontSize);
// Stable refs for callbacks so the mount effect doesn't need to re-run when
// parents pass new inline functions, while still always calling the latest version.
const onStatusRef = useRef(onStatus);
const onSpawnRef = useRef(onSpawn);
const onInputRef = useRef(onInput);
const onDataReceivedRef = useRef(onDataReceived);
const onFocusRef = useRef(onFocus);
useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]);
useEffect(() => { onSpawnRef.current = onSpawn; }, [onSpawn]);
useEffect(() => { onInputRef.current = onInput; }, [onInput]);
useEffect(() => { onDataReceivedRef.current = onDataReceived; }, [onDataReceived]);
useEffect(() => { onFocusRef.current = onFocus; }, [onFocus]);
// -------------------------------------------------------------------------
// Mount / unmount: create terminal, spawn PTY, wire listeners
// -------------------------------------------------------------------------
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let term: Terminal | null = new Terminal({
fontFamily: '"Cascadia Mono", "JetBrains Mono", "Consolas", monospace',
fontSize: initialFontSizeRef.current ?? DEFAULT_XTERM_FONT_SIZE,
cursorBlink: true,
theme: {
background: "#0c0c0c",
foreground: "#e6e6e6",
},
scrollback: 5000,
convertEol: false,
allowProposedApi: true,
});
termRef.current = term;
const fit = new FitAddon();
fitRef.current = fit;
term.loadAddon(fit);
// Underlines http(s) URLs in the terminal output and routes clicks
// through Tauri's opener plugin so they open in the user's default
// browser (WebView2 won't navigate on a plain window.open).
term.loadAddon(
new WebLinksAddon((_event, uri) => {
void openUrl(uri).catch((err) =>
console.warn("openUrl failed:", err),
);
}),
);
term.open(container);
// Initial size — fit before asking the PTY for its dimensions.
fit.fit();
let paneId: PaneId | null = null;
let unlistenData: UnlistenFn | null = null;
let unlistenExit: UnlistenFn | null = null;
let ro: ResizeObserver | null = null;
let destroyed = false;
(async () => {
const cols = term!.cols;
const rows = term!.rows;
if (existingPaneId != null) {
// Adoption path: a window-transfer landed us here with an existing
// PTY id. Don't spawn — replay the scrollback ring first (so the
// user sees recent output like a thinking Claude session), then
// attach the live listener, resize the PTY to this window's grid,
// and release the transfer-refcount.
paneId = existingPaneId;
paneIdRef.current = paneId;
onStatusRef.current?.(`pane ${paneId} adopted`, true);
onSpawnRef.current?.(paneId);
try {
const ringB64 = await getPaneRing(paneId);
if (destroyed) return;
if (ringB64) {
term?.write(b64ToBytes(ringB64));
}
} catch (e) {
console.warn("getPaneRing failed:", e);
}
if (destroyed) return;
unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64));
onDataReceivedRef.current?.();
});
// `destroyed` may have flipped during the await — the sync cleanup
// already ran and captured a null unlisten, so unlisten here or the
// subscription leaks.
if (destroyed) {
unlistenData?.();
return;
}
unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
onStatusRef.current?.(`pane ${paneId} exited`, false);
});
if (destroyed) {
unlistenData?.();
unlistenExit?.();
return;
}
// Match the PTY to our cell grid (the source window may have had
// different dimensions).
try {
await resizePane(paneId, cols, rows);
} catch (e) {
console.warn("resizePane on adopt failed:", e);
}
// Release the transfer refcount so future killPane calls on this
// id are no longer suppressed.
try {
await claimPane(paneId);
} catch (e) {
console.warn("claimPane failed:", e);
}
} else {
try {
paneId = await spawnPane({ spec, cols, rows });
if (destroyed) {
void killPane(paneId);
return;
}
paneIdRef.current = paneId;
onStatusRef.current?.(`pane ${paneId} alive`, true);
onSpawnRef.current?.(paneId);
} catch (e) {
if (destroyed) return;
const msg = `spawn_pane failed: ${e}`;
term?.write(`\r\n\x1b[31m${msg}\x1b[0m\r\n`);
onStatusRef.current?.(msg, false);
return;
}
unlistenData = await onPaneData(paneId, (b64) => {
term?.write(b64ToBytes(b64));
onDataReceivedRef.current?.();
});
if (destroyed) {
unlistenData?.();
return;
}
unlistenExit = await onPaneExit(paneId, () => {
term?.write("\r\n\x1b[33m[pane exited]\x1b[0m\r\n");
onStatusRef.current?.(`pane ${paneId} exited`, false);
});
if (destroyed) {
unlistenData?.();
unlistenExit?.();
return;
}
}
term?.onData((data) => {
if (paneId == null) return;
const b64 = stringToB64(data);
void writeToPane(paneId, b64);
onInputRef.current?.(b64);
});
// Ctrl+Shift+C / Ctrl+Shift+V — copy selection / paste from clipboard.
// Runs before xterm consumes the key, so the textarea never sees a raw
// Ctrl+V (which would otherwise inject ^V into the PTY). term.paste()
// routes through onData → writeToPane, so broadcasting and bracketed
// paste both keep working for free.
//
// Uses tauri-plugin-clipboard-manager instead of navigator.clipboard so
// WebView2 doesn't surface its native "Allow clipboard access?" prompt.
term?.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
if (!e.ctrlKey || !e.shiftKey || e.altKey) return true;
if (e.code === "KeyC") {
const sel = term?.getSelection();
if (sel) {
void clipboardWriteText(sel).catch((err) =>
console.warn("clipboard write failed:", err),
);
}
e.preventDefault();
return false;
}
if (e.code === "KeyV") {
e.preventDefault();
clipboardReadText()
.then((text) => {
if (text && term) term.paste(text);
})
.catch((err) => console.warn("clipboard read failed:", err));
return false;
}
return true;
});
// Focus detection: xterm.js doesn't expose onFocus as a first-class event
// in all versions, so try the proposed API first then fall back to the DOM.
term?.onSelectionChange(() => {}); // ensure addon system is initialised; noop
const termAny = term as unknown as { onFocus?: (cb: () => void) => void };
if (typeof termAny.onFocus === "function") {
termAny.onFocus(() => onFocusRef.current?.());
} else {
const ta = container.querySelector(".xterm-helper-textarea");
if (ta) ta.addEventListener("focus", () => onFocusRef.current?.(), true);
}
// Re-fit on container resize. xterm.fit() + a forced refresh run
// immediately (visual must stay smooth during a drag), but the
// actual PTY resize call is debounced: every SIGWINCH makes bash
// redraw the prompt, and if we send 60+ of them per second during a
// gutter drag, the redraws corrupt each other and the terminal
// fills with garbled half-prompts. The debounce means the PTY
// hears about resizes ~150 ms after you stop dragging, at the
// final size — bash gets a single clean redraw.
let resizeRaf: number | null = null;
let resizePtyTimer: number | null = null;
let lastSentCols = -1;
let lastSentRows = -1;
ro = new ResizeObserver(() => {
if (resizeRaf != null) return;
resizeRaf = requestAnimationFrame(() => {
resizeRaf = null;
if (!term) return;
try {
fit.fit();
term.refresh(0, term.rows - 1);
if (resizePtyTimer != null) clearTimeout(resizePtyTimer);
resizePtyTimer = window.setTimeout(() => {
resizePtyTimer = null;
if (paneId == null || !term) return;
// Skip if the cell grid didn't actually change — saves a
// pointless SIGWINCH that would make bash redraw its prompt
// (which feeds back into onDataReceived and causes the idle
// indicator to flap; see the analysis around v0.2.2).
if (term.cols === lastSentCols && term.rows === lastSentRows) {
return;
}
lastSentCols = term.cols;
lastSentRows = term.rows;
void resizePane(paneId, term.cols, term.rows);
}, 150);
} catch (e) {
console.warn("resize failed", e);
}
});
});
ro.observe(container);
// Focus so typing immediately lands in the terminal — but ONLY if the
// host container is actually visible. With multiple tabs (workspaces),
// a pane in a hidden tab still mounts and spawns; we must not yank
// focus into a tab the user can't see. CSS `visibility: hidden` is
// inherited, so the computed style on the container reflects whether
// any ancestor (workspace-layer) is hiding us.
if (
container.isConnected &&
getComputedStyle(container).visibility !== "hidden"
) {
term?.focus();
}
})();
return () => {
destroyed = true;
ro?.disconnect();
unlistenData?.();
unlistenExit?.();
if (paneId != null) void killPane(paneId);
term?.dispose();
term = null;
termRef.current = null;
fitRef.current = null;
paneIdRef.current = null;
};
// spec is read once at mount; intentionally omitted from deps so we
// don't remount on parent re-renders. Callers force a respawn by
// bumping the React `key` (changeShell swaps the leaf id for that).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// -------------------------------------------------------------------------
// focusTrigger: programmatic refocus from parent (palette navigation etc.)
// -------------------------------------------------------------------------
useEffect(() => {
if (focusTrigger > 0 && containerRef.current) {
const ta = containerRef.current.querySelector<HTMLTextAreaElement>(
".xterm-helper-textarea",
);
ta?.focus();
}
}, [focusTrigger]);
// -------------------------------------------------------------------------
// Live font-size changes (Ctrl+Shift+= / - / 0).
//
// Setting term.options.fontSize re-rasterises glyphs immediately, but the
// cols/rows the terminal thinks it has are still based on the OLD cell
// size — so we have to fit() to recompute, refresh() to repaint, then
// ship the new dimensions to the PTY so bash redraws the prompt at the
// right width.
// -------------------------------------------------------------------------
useEffect(() => {
const term = termRef.current;
const fit = fitRef.current;
if (!term || !fit) return;
const target = fontSize ?? DEFAULT_XTERM_FONT_SIZE;
if (term.options.fontSize === target) return;
try {
term.options.fontSize = target;
fit.fit();
term.refresh(0, term.rows - 1);
const paneId = paneIdRef.current;
if (paneId != null) void resizePane(paneId, term.cols, term.rows);
} catch (e) {
console.warn("font-size apply failed", e);
}
}, [fontSize]);
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
}