Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions apps/desktop/src/main/terminal-host/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ const EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES = 1_000_000;
*/
const EMULATOR_WRITE_QUEUE_LOW_WATERMARK_BYTES = 250_000;

/**
* When no renderer clients are attached, defer headless emulator catch-up into
* coarse idle batches instead of chasing every PTY chunk immediately. Attach,
* shell init and backpressure still force urgent drains.
*/
const EMULATOR_IDLE_DRAIN_INTERVAL_MS = 250;

/**
* How long to wait for the shell-ready marker before unblocking writes.
* 15s covers heavy setups like Nix-based devenv via direnv. On timeout,
Expand Down Expand Up @@ -203,6 +210,7 @@ export class Session {
private emulatorWriteQueue: string[] = [];
private emulatorWriteQueuedBytes = 0;
private emulatorWriteScheduled = false;
private emulatorIdleDrainTimer: ReturnType<typeof setTimeout> | null = null;
private emulatorFlushWaiters: Array<() => void> = [];

// Broadcast data coalescing — see BROADCAST_COALESCE_* constants.
Expand Down Expand Up @@ -622,16 +630,50 @@ export class Session {
this.scheduleEmulatorWrite();
}

private scheduleEmulatorWrite(): void {
if (this.emulatorWriteScheduled || this.disposed) return;
this.emulatorWriteScheduled = true;
setImmediate(() => {
this.processEmulatorWriteQueue();
});
private shouldUseIdleEmulatorDrain(): boolean {
return (
this.attachedClients.size === 0 &&
this.shellReadyState !== "pending" &&
!this.emulatorWriteBackpressured &&
this.snapshotBoundaryWaiters.length === 0 &&
this.emulatorFlushWaiters.length === 0 &&
this.emulatorWriteQueuedBytes < EMULATOR_WRITE_QUEUE_HIGH_WATERMARK_BYTES
);
}

private clearIdleEmulatorDrainTimer(): void {
if (!this.emulatorIdleDrainTimer) return;
clearTimeout(this.emulatorIdleDrainTimer);
this.emulatorIdleDrainTimer = null;
}

private scheduleEmulatorWrite(options?: { urgent?: boolean }): void {
if (this.disposed || this.emulatorWriteScheduled) return;

const urgent = options?.urgent ?? false;
if (urgent || !this.shouldUseIdleEmulatorDrain()) {
this.clearIdleEmulatorDrainTimer();
this.emulatorWriteScheduled = true;
setImmediate(() => {
this.processEmulatorWriteQueue();
});
return;
}

if (this.emulatorIdleDrainTimer) return;
this.emulatorIdleDrainTimer = setTimeout(() => {
this.emulatorIdleDrainTimer = null;
if (this.disposed || this.emulatorWriteScheduled) return;
this.emulatorWriteScheduled = true;
setImmediate(() => {
this.processEmulatorWriteQueue();
});
}, EMULATOR_IDLE_DRAIN_INTERVAL_MS);
}

private processEmulatorWriteQueue(): void {
if (this.disposed) {
this.clearIdleEmulatorDrainTimer();
this.emulatorWriteQueue = [];
this.emulatorWriteQueuedBytes = 0;
this.emulatorWriteProcessedItems = 0;
Expand Down Expand Up @@ -716,9 +758,8 @@ export class Session {
this.maybeResumeSubprocessStdoutForEmulatorBackpressure();

if (this.emulatorWriteQueue.length > 0) {
setImmediate(() => {
this.processEmulatorWriteQueue();
});
this.emulatorWriteScheduled = false;
this.scheduleEmulatorWrite();
return;
}

Expand Down Expand Up @@ -776,7 +817,7 @@ export class Session {
resolve();
},
});
this.scheduleEmulatorWrite();
this.scheduleEmulatorWrite({ urgent: true });
this.resolveReachedSnapshotBoundaryWaiters();
});

Expand Down Expand Up @@ -1029,6 +1070,7 @@ export class Session {
dispose(): Promise<void> {
if (this.disposed) return Promise.resolve();
this.disposed = true;
this.clearIdleEmulatorDrainTimer();

const pidsToKill = this.collectProcessPids();

Expand Down Expand Up @@ -1081,6 +1123,7 @@ export class Session {
this.emulatorWriteProcessedItems = 0;
this.nextSnapshotBoundaryWaiterId = 1;
this.emulatorWriteScheduled = false;
this.clearIdleEmulatorDrainTimer();
this.resolveAllSnapshotBoundaryWaiters();
const waiters = this.emulatorFlushWaiters;
this.emulatorFlushWaiters = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,8 @@ export const Terminal = memo(function Terminal({
});

// Stream event handler registration — the subscription itself lives in
// v1TerminalCache and stays alive across mount/unmount cycles so data
// keeps flowing to xterm even while the tab is hidden.
// v1TerminalCache and stays alive across mount/unmount cycles. Hidden
// terminals buffer events for replay instead of burning CPU on xterm.write.
// Placed after useTerminalLifecycle so the cache entry exists on cold mount.
// Gated on xtermInstance so it re-runs once the lifecycle hook creates it.
useEffect(() => {
Expand All @@ -505,8 +505,8 @@ export const Terminal = memo(function Terminal({
},
});

// Process lifecycle events (exit, error, disconnect) that arrived
// while this component was unmounted.
// Process any buffered events that arrived while this component was
// unmounted. Data events are already coalesced by the cache.
for (const event of queuedEvents) {
handleStreamData(event);
}
Expand All @@ -516,6 +516,14 @@ export const Terminal = memo(function Terminal({
};
}, [paneId, xtermInstance, handleStreamData, setConnectionError]);

useEffect(() => {
if (!xtermInstance) return;
v1TerminalCache.setFocused(paneId, isFocused);
if (isFocused) {
xtermRef.current?.focus();
}
}, [paneId, isFocused, xtermInstance]);

useEffect(() => {
const xterm = xtermRef.current;
if (!xterm) return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
wrapper: HTMLDivElement;
linkManager: TerminalLinkManager;
openOnce: () => void;
setGpuAccelerationEnabled: (enabled: boolean) => void;
cleanup: () => void;
} {
const {
Expand All @@ -124,6 +125,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
let opened = false;
let webglAddon: WebglAddon | null = null;
let webglRafId: number | null = null;
let gpuAccelerationEnabled = false;

// Create a detached wrapper div. xterm.open() is deferred until the wrapper
// is attached to a live DOM container.
Expand All @@ -146,15 +148,63 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
// Ligatures not supported by current font
}

const openOnce = () => {
if (disposed || opened) return;
opened = true;
xterm.open(wrapper);
const disposeWebglAddon = () => {
if (webglRafId !== null) {
cancelAnimationFrame(webglRafId);
webglRafId = null;
}
if (!webglAddon) return;
try {
webglAddon.dispose();
} catch (error) {
terminalRendererDebug.warn(
"webgl-addon-dispose-failed",
{
errorMessage: error instanceof Error ? error.message : String(error),
},
{
captureMessage: true,
fingerprint: ["terminal.renderer", "webgl-dispose-failed"],
},
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
webglAddon = null;
if (opened && !disposed) {
xterm.refresh(0, Math.max(0, xterm.rows - 1));
}
};

// scheduleWebglEnable / disposeWebglAddon are mutually exclusive:
// disposeWebglAddon cancels any pending RAF and nulls webglRafId before it
// returns, so a rapid attach -> detach -> attach sequence cannot leave two
// WebglAddon instances loaded — the pending callback bails out via the
// disposed / !gpuAccelerationEnabled / webglAddon guards below.
const scheduleWebglEnable = () => {
if (
disposed ||
!gpuAccelerationEnabled ||
!opened ||
!wrapper.isConnected ||
suggestedRendererType === "dom" ||
webglAddon ||
webglRafId !== null
) {
return;
}

// Defer WebGL until after open() so renderer initialization sees a live DOM node.
// Defer WebGL until after open()/attach so renderer initialization
// sees a live DOM node.
webglRafId = requestAnimationFrame(() => {
webglRafId = null;
if (disposed || suggestedRendererType === "dom") return;
if (
disposed ||
!gpuAccelerationEnabled ||
!wrapper.isConnected ||
suggestedRendererType === "dom" ||
webglAddon
) {
return;
}

try {
webglAddon = new WebglAddon();
Expand All @@ -168,7 +218,7 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
captureMessage: true,
fingerprint: ["terminal.renderer", "webgl-context-lost"],
});
xterm.refresh(0, xterm.rows - 1);
xterm.refresh(0, Math.max(0, xterm.rows - 1));
});
xterm.loadAddon(webglAddon);
} catch {
Expand All @@ -182,6 +232,22 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
});
};

const setGpuAccelerationEnabled = (enabled: boolean) => {
gpuAccelerationEnabled = enabled;
if (!enabled) {
disposeWebglAddon();
return;
}
scheduleWebglEnable();
};

const openOnce = () => {
if (disposed || opened) return;
opened = true;
xterm.open(wrapper);
scheduleWebglEnable();
};

const cleanupQuerySuppression = suppressQueryResponses(xterm);

const linkManager = new TerminalLinkManager(xterm);
Expand Down Expand Up @@ -244,17 +310,12 @@ export function createTerminalInWrapper(options: CreateTerminalOptions = {}): {
wrapper,
linkManager,
openOnce,
setGpuAccelerationEnabled,
cleanup: () => {
disposed = true;
if (webglRafId !== null) {
cancelAnimationFrame(webglRafId);
}
disposeWebglAddon();
cleanupQuerySuppression();
linkManager.dispose();
try {
webglAddon?.dispose();
} catch {}
webglAddon = null;
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ export function useTerminalLifecycle({
handleFileLinkClickRef.current(event, link),
onUrlClickRef: handleUrlClickRef,
});
v1TerminalCache.setFocused(paneId, isFocusedRef.current);

const { xterm, fitAddon, searchAddon } = cached;

Expand Down
Loading
Loading