Fix terminal resize race (#2059)

This commit is contained in:
Christoph Leiter
2026-07-16 23:47:09 +02:00
committed by GitHub
parent a622860a3e
commit 9f5f5fce62
11 changed files with 644 additions and 174 deletions
@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { resolveFocusLatchStep, type FocusLatchStep } from "./terminal-pane-focus-latch";
/**
* Pins the ordering that caused "terminal stuck at 80x24": the latch must record that the action
* RAN, not that the effect ran. A pane that mounts before its workspace has focus (or while the
* app is hidden) must still fire once readiness arrives — burning the latch on the attempt
* permanently disarms the only resize claim a fresh terminal ever sends.
*/
function run(steps: Array<{ key: string | null; canFire: boolean }>): FocusLatchStep[] {
const results: FocusLatchStep[] = [];
let latchedKey: string | null = null;
for (const step of steps) {
const result = resolveFocusLatchStep({ ...step, latchedKey });
latchedKey = result.latchedKey;
results.push(result);
}
return results;
}
describe("resolveFocusLatchStep", () => {
it("fires immediately when the pane is focused and ready at mount", () => {
const [step] = run([{ key: "ws:term-1", canFire: true }]);
expect(step).toEqual({ latchedKey: "ws:term-1", fire: true });
});
it("defers instead of burning the latch when readiness arrives late", () => {
// The bug: pane focused while the workspace is not (or the app is hidden). The old code
// latched on the first pass and never fired again.
const steps = run([
{ key: "ws:term-1", canFire: false }, // mount: pane focused, workspace not focused yet
{ key: "ws:term-1", canFire: true }, // workspace focus / visibility arrives
]);
expect(steps[0]).toEqual({ latchedKey: null, fire: false });
expect(steps[1]).toEqual({ latchedKey: "ws:term-1", fire: true });
});
it("fires exactly once per continuous pane-focus period", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-1", canFire: true }, // unrelated re-render
{ key: "ws:term-1", canFire: false }, // workspace blurs...
{ key: "ws:term-1", canFire: true }, // ...and refocuses: not a new pane-focus period
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-1", fire: false },
{ latchedKey: "ws:term-1", fire: false },
{ latchedKey: "ws:term-1", fire: false },
]);
});
it("re-arms when the pane blurs and fires again on refocus", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: null, canFire: true }, // pane blurred / terminal gone
{ key: "ws:term-1", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: null, fire: false },
{ latchedKey: "ws:term-1", fire: true },
]);
});
it("fires for a different terminal in the same pane", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-2", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-2", fire: true },
]);
});
it("stays deferred across repeated not-ready passes, then fires once", () => {
const steps = run([
{ key: "ws:term-1", canFire: false },
{ key: "ws:term-1", canFire: false },
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-1", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: null, fire: false },
{ latchedKey: null, fire: false },
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-1", fire: false },
]);
});
});
@@ -0,0 +1,33 @@
/**
* Once-per-pane-focus latch for terminal-pane's focus/reflow effects.
*
* Each effect wants to fire exactly once per continuous pane-focus period per terminal, but only
* while the action can actually land (workspace focused, and for resize claims the app visible —
* `handleTerminalResize` drops claims emitted while hidden and nothing re-sends them). The latch
* must therefore record "the action ran", never "we tried": latching before the readiness gate
* permanently disarms the effect when a pane mounts before its workspace has focus, which is how
* a terminal ends up rendering full-size while its PTY stays at the daemon's 80x24 default.
*/
export interface FocusLatchStep {
latchedKey: string | null;
fire: boolean;
}
export function resolveFocusLatchStep(input: {
/** Identity of the pane-focus period; null resets the latch (pane blurred, no terminal). */
key: string | null;
latchedKey: string | null;
/** Whether the action can land right now; while false the latch stays untouched. */
canFire: boolean;
}): FocusLatchStep {
if (input.key === null) {
return { latchedKey: null, fire: false };
}
if (input.latchedKey === input.key) {
return { latchedKey: input.latchedKey, fire: false };
}
if (!input.canFire) {
return { latchedKey: input.latchedKey, fire: false };
}
return { latchedKey: input.key, fire: true };
}
+31 -35
View File
@@ -21,7 +21,7 @@ import {
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import { rememberTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache";
import { resolveFocusLatchStep } from "./terminal-pane-focus-latch";
import {
TerminalStreamController,
type TerminalStreamControllerStatus,
@@ -261,42 +261,41 @@ export function TerminalPane({
);
useEffect(() => {
if (isMobile || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
// Latch only when the focus actually ran: latching before the workspace-focus gate would
// permanently disarm this effect for panes that mount before their workspace has focus.
const step = resolveFocusLatchStep({
key: isMobile || !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`,
latchedKey: lastAutoFocusKeyRef.current,
canFire: isWorkspaceFocused,
});
lastAutoFocusKeyRef.current = step.latchedKey;
if (step.fire) {
requestTerminalFocus();
}
const nextFocusKey = `${scopeKey}:${terminalId}`;
if (lastAutoFocusKeyRef.current === nextFocusKey) {
return;
}
lastAutoFocusKeyRef.current = nextFocusKey;
if (!isWorkspaceFocused) {
return;
}
requestTerminalFocus();
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => {
if (!isPaneFocused || !terminalId) {
lastPaneFocusResizeKeyRef.current = null;
return;
// This reflow produces the one resize claim a freshly mounted terminal ever sends; defer it
// (without burning the latch) until the claim can land — handleTerminalResize drops claims
// while the workspace is unfocused or the app is hidden, and nothing re-sends a dropped one.
const step = resolveFocusLatchStep({
key: !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`,
latchedKey: lastPaneFocusResizeKeyRef.current,
canFire: isWorkspaceFocused && isAppVisible,
});
lastPaneFocusResizeKeyRef.current = step.latchedKey;
if (step.fire) {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}
const focusResizeKey = `${scopeKey}:${terminalId}`;
if (lastPaneFocusResizeKeyRef.current === focusResizeKey) {
return;
}
lastPaneFocusResizeKeyRef.current = focusResizeKey;
if (!isWorkspaceFocused) {
return;
}
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, scopeKey, terminalId]);
}, [
isAppVisible,
isPaneFocused,
isWorkspaceFocused,
requestTerminalReflow,
scopeKey,
terminalId,
]);
const handleTerminalFocus = useCallback(() => {
if (isWorkspaceFocused && isPaneFocused) {
@@ -636,9 +635,6 @@ export function TerminalPane({
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
// Seed future terminals in this workspace with the current pane size so they are born at
// the right size instead of the daemon's 80x24 default (see terminal-size-cache).
rememberTerminalViewportSize({ serverId, cwd, size: nextSize });
if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
return;
}