Fix Safari crashes: share WASM singleton and fix resource leaks
- Share single Ghostty WASM instance across all terminals instead of loading a new one per tab (major memory savings on Safari) - Track all window/document event listeners and remove them on dispose() - Store and disconnect ResizeObserver on dispose() - Clear resize debounce timer on dispose() - Remove injected mobile keybar <style> element on dispose() - Null out socket and clear message queue on dispose() - Use addTrackedListener() helper for automatic cleanup registration
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -13,6 +13,19 @@ const MAX_MESSAGE_QUEUE_SIZE = 1000;
|
|||||||
/** How often to run periodic resource cleanup (ms) */
|
/** How often to run periodic resource cleanup (ms) */
|
||||||
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
|
const RESOURCE_CLEANUP_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
/** Shared Ghostty WASM instance (loaded once, reused across all terminals) */
|
||||||
|
let sharedGhostty: Ghostty | null = null;
|
||||||
|
|
||||||
|
/** Load or reuse the shared Ghostty WASM instance */
|
||||||
|
async function getSharedGhostty(): Promise<Ghostty> {
|
||||||
|
if (!sharedGhostty) {
|
||||||
|
const wasmPath = getWasmPath();
|
||||||
|
console.log("[webterm] Loading shared Ghostty WASM:", wasmPath);
|
||||||
|
sharedGhostty = await Ghostty.load(wasmPath);
|
||||||
|
}
|
||||||
|
return sharedGhostty;
|
||||||
|
}
|
||||||
|
|
||||||
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
||||||
const DEFAULT_FONT_FAMILY =
|
const DEFAULT_FONT_FAMILY =
|
||||||
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
|
'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' +
|
||||||
@@ -598,6 +611,9 @@ class WebTerminal {
|
|||||||
private fontFamily: string;
|
private fontFamily: string;
|
||||||
private fontSize: number;
|
private fontSize: number;
|
||||||
private cleanupTimer: number | undefined;
|
private cleanupTimer: number | undefined;
|
||||||
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
|
private mobileKeybarStyle: HTMLStyleElement | null = null;
|
||||||
|
private boundHandlers: { target: EventTarget; type: string; handler: EventListener; options?: boolean | AddEventListenerOptions }[] = [];
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
@@ -615,6 +631,17 @@ class WebTerminal {
|
|||||||
this.fontSize = fontSize;
|
this.fontSize = fontSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Register an event listener that will be removed on dispose */
|
||||||
|
private addTrackedListener(
|
||||||
|
target: EventTarget,
|
||||||
|
type: string,
|
||||||
|
handler: EventListener,
|
||||||
|
options?: boolean | AddEventListenerOptions
|
||||||
|
): void {
|
||||||
|
target.addEventListener(type, handler, options);
|
||||||
|
this.boundHandlers.push({ target, type, handler, options });
|
||||||
|
}
|
||||||
|
|
||||||
/** Create and initialize a WebTerminal instance */
|
/** Create and initialize a WebTerminal instance */
|
||||||
static async create(
|
static async create(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
@@ -629,8 +656,8 @@ class WebTerminal {
|
|||||||
// Determine WASM path and pre-load Ghostty
|
// Determine WASM path and pre-load Ghostty
|
||||||
const wasmPath = getWasmPath();
|
const wasmPath = getWasmPath();
|
||||||
console.log("[webterm:create] WASM path:", wasmPath);
|
console.log("[webterm:create] WASM path:", wasmPath);
|
||||||
console.log("[webterm:create] Loading Ghostty WASM...");
|
console.log("[webterm:create] Loading shared Ghostty WASM...");
|
||||||
const ghostty = await Ghostty.load(wasmPath);
|
const ghostty = await getSharedGhostty();
|
||||||
console.log("[webterm:create] Ghostty loaded:", ghostty);
|
console.log("[webterm:create] Ghostty loaded:", ghostty);
|
||||||
|
|
||||||
// Build terminal options
|
// Build terminal options
|
||||||
@@ -761,7 +788,7 @@ class WebTerminal {
|
|||||||
this.setupResizeObserver();
|
this.setupResizeObserver();
|
||||||
|
|
||||||
// Handle window resize (some browsers don't trigger ResizeObserver on window resize)
|
// Handle window resize (some browsers don't trigger ResizeObserver on window resize)
|
||||||
window.addEventListener("resize", () => {
|
this.addTrackedListener(window, "resize", () => {
|
||||||
this.fit();
|
this.fit();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -802,19 +829,19 @@ class WebTerminal {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Focus terminal when returning to the tab
|
// Focus terminal when returning to the tab
|
||||||
document.addEventListener("visibilitychange", () => {
|
this.addTrackedListener(document, "visibilitychange", () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
restoreFocus();
|
restoreFocus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore focus when browser window regains focus
|
// Restore focus when browser window regains focus
|
||||||
window.addEventListener("focus", () => {
|
this.addTrackedListener(window, "focus", () => {
|
||||||
restoreFocus();
|
restoreFocus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Safari can restore tabs via bfcache without a focus event.
|
// Safari can restore tabs via bfcache without a focus event.
|
||||||
window.addEventListener("pageshow", () => {
|
this.addTrackedListener(window, "pageshow", () => {
|
||||||
restoreFocus();
|
restoreFocus();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -980,9 +1007,10 @@ class WebTerminal {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Apply keybar modifiers to physical keyboard input even when the textarea isn't focused.
|
// Apply keybar modifiers to physical keyboard input even when the textarea isn't focused.
|
||||||
document.addEventListener(
|
this.addTrackedListener(
|
||||||
|
document,
|
||||||
"keydown",
|
"keydown",
|
||||||
(event) => {
|
((event: KeyboardEvent) => {
|
||||||
if (!this.ctrlActive && !this.shiftActive && !this.altActive && !this.fnActive) {
|
if (!this.ctrlActive && !this.shiftActive && !this.altActive && !this.fnActive) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1051,7 +1079,7 @@ class WebTerminal {
|
|||||||
if (handled) {
|
if (handled) {
|
||||||
this.deactivateModifiers();
|
this.deactivateModifiers();
|
||||||
}
|
}
|
||||||
},
|
}) as EventListener,
|
||||||
{ capture: true }
|
{ capture: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1062,8 +1090,8 @@ class WebTerminal {
|
|||||||
this.mobileInput?.focus();
|
this.mobileInput?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
this.element.addEventListener("touchend", focusTextarea, { passive: true });
|
this.addTrackedListener(this.element, "touchend", focusTextarea, { passive: true });
|
||||||
this.element.addEventListener("click", focusTextarea);
|
this.addTrackedListener(this.element, "click", focusTextarea);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupTouchSelection(): void {
|
private setupTouchSelection(): void {
|
||||||
@@ -1188,6 +1216,7 @@ class WebTerminal {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
this.mobileKeybarStyle = style;
|
||||||
document.body.appendChild(keybar);
|
document.body.appendChild(keybar);
|
||||||
this.mobileKeybar = keybar;
|
this.mobileKeybar = keybar;
|
||||||
|
|
||||||
@@ -1438,7 +1467,7 @@ class WebTerminal {
|
|||||||
|
|
||||||
/** Setup resize observer for container */
|
/** Setup resize observer for container */
|
||||||
private setupResizeObserver(): void {
|
private setupResizeObserver(): void {
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
// Debounce resize events
|
// Debounce resize events
|
||||||
if (this.resizeDebounceTimer) {
|
if (this.resizeDebounceTimer) {
|
||||||
clearTimeout(this.resizeDebounceTimer);
|
clearTimeout(this.resizeDebounceTimer);
|
||||||
@@ -1447,7 +1476,7 @@ class WebTerminal {
|
|||||||
this.fit();
|
this.fit();
|
||||||
}, 100);
|
}, 100);
|
||||||
});
|
});
|
||||||
resizeObserver.observe(this.element);
|
this.resizeObserver.observe(this.element);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resizeDebounceTimer: number | undefined;
|
private resizeDebounceTimer: number | undefined;
|
||||||
@@ -1650,7 +1679,23 @@ class WebTerminal {
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stopResourceCleanup();
|
this.stopResourceCleanup();
|
||||||
this.stopHeartbeatWatchdog();
|
this.stopHeartbeatWatchdog();
|
||||||
|
if (this.resizeDebounceTimer) {
|
||||||
|
clearTimeout(this.resizeDebounceTimer);
|
||||||
|
this.resizeDebounceTimer = undefined;
|
||||||
|
}
|
||||||
this.socket?.close();
|
this.socket?.close();
|
||||||
|
this.socket = null;
|
||||||
|
this.messageQueue.length = 0;
|
||||||
|
// Remove all tracked event listeners
|
||||||
|
for (const { target, type, handler, options } of this.boundHandlers) {
|
||||||
|
target.removeEventListener(type, handler, options);
|
||||||
|
}
|
||||||
|
this.boundHandlers.length = 0;
|
||||||
|
// Disconnect resize observer
|
||||||
|
if (this.resizeObserver) {
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
|
this.resizeObserver = null;
|
||||||
|
}
|
||||||
if (this.mobileInput) {
|
if (this.mobileInput) {
|
||||||
this.mobileInput.remove();
|
this.mobileInput.remove();
|
||||||
this.mobileInput = null;
|
this.mobileInput = null;
|
||||||
@@ -1659,6 +1704,10 @@ class WebTerminal {
|
|||||||
this.mobileKeybar.remove();
|
this.mobileKeybar.remove();
|
||||||
this.mobileKeybar = null;
|
this.mobileKeybar = null;
|
||||||
}
|
}
|
||||||
|
if (this.mobileKeybarStyle) {
|
||||||
|
this.mobileKeybarStyle.remove();
|
||||||
|
this.mobileKeybarStyle = null;
|
||||||
|
}
|
||||||
this.fitAddon.dispose();
|
this.fitAddon.dispose();
|
||||||
this.terminal.dispose();
|
this.terminal.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user