fix(FN-1135): retry xterm imports when MIME errors occur
- Add retryDynamicImport helper in TerminalModal with targeted retry detection for MIME type and dynamic import fetch failures - Retry xterm module imports with backoff delays (500ms, 1500ms, 3000ms) and preserve original error reporting when retries are exhausted - Add TerminalModal tests covering successful retry recovery, exhausted retry fallback to init error UI, and no-retry behavior for non-retryable failures - Add a changeset patch for @gsxdsm/fusion describing the terminal initialization fix
This commit is contained in:
@@ -10,6 +10,62 @@ import type { FitAddon } from "@xterm/addon-fit";
|
||||
/** Timeout for xterm.js dynamic imports + terminal.open() setup. */
|
||||
const XTERM_INIT_TIMEOUT_MS = 10000;
|
||||
|
||||
const XTERM_IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000] as const;
|
||||
|
||||
function isRetryableDynamicImportError(error: unknown): boolean {
|
||||
const message =
|
||||
typeof error === "string"
|
||||
? error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
|
||||
return (
|
||||
message.includes("MIME type") ||
|
||||
message.includes("Failed to fetch dynamically imported module")
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function retryDynamicImport<T>(
|
||||
importFactory: () => Promise<T>,
|
||||
retryDelaysMs: readonly number[] = XTERM_IMPORT_RETRY_DELAYS_MS,
|
||||
): Promise<T> {
|
||||
let originalError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
|
||||
try {
|
||||
return await importFactory();
|
||||
} catch (error) {
|
||||
if (!isRetryableDynamicImportError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (originalError === undefined) {
|
||||
originalError = error;
|
||||
}
|
||||
|
||||
const delayMs = retryDelaysMs[attempt];
|
||||
if (delayMs === undefined) {
|
||||
throw originalError ?? error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`[TerminalModal] Dynamic xterm import failed (attempt ${attempt + 1}/${retryDelaysMs.length + 1}). Retrying in ${delayMs}ms...`,
|
||||
message,
|
||||
);
|
||||
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
throw originalError ?? new Error("Dynamic import failed");
|
||||
}
|
||||
|
||||
/** Whether the current device is likely mobile (touch-primary, small viewport). */
|
||||
function isMobileDevice(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -241,11 +297,13 @@ export function TerminalModal({ isOpen, onClose, initialCommand }: TerminalModal
|
||||
|
||||
const initTerminal = async () => {
|
||||
// Dynamically import xterm modules with watchdog timeout
|
||||
const importsPromise = Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-web-links"),
|
||||
]);
|
||||
const importsPromise = retryDynamicImport(() =>
|
||||
Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-web-links"),
|
||||
]),
|
||||
);
|
||||
|
||||
// Watchdog: reject if imports + setup take too long
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
|
||||
@@ -843,6 +843,115 @@ describe("TerminalModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("xterm import MIME type retry", () => {
|
||||
function isXtermImportBatch(values: Iterable<unknown>): values is Promise<unknown>[] {
|
||||
return (
|
||||
Array.isArray(values) &&
|
||||
values.length === 3 &&
|
||||
values.every((entry) => entry && typeof (entry as Promise<unknown>).then === "function")
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries MIME type import failures and initializes successfully on a later attempt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const originalPromiseAll = Promise.all.bind(Promise);
|
||||
let importAttempts = 0;
|
||||
|
||||
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
|
||||
if (isXtermImportBatch(values)) {
|
||||
importAttempts += 1;
|
||||
if (importAttempts === 1) {
|
||||
return Promise.reject(
|
||||
new Error("'text/html' is not a valid JavaScript MIME type"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return originalPromiseAll(values);
|
||||
}) as typeof Promise.all);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTerminalInstance.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(importAttempts).toBe(2);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId("terminal-xterm-init-error")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows xterm init error UI when MIME type import retries are exhausted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const originalPromiseAll = Promise.all.bind(Promise);
|
||||
let importAttempts = 0;
|
||||
|
||||
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
|
||||
if (isXtermImportBatch(values)) {
|
||||
importAttempts += 1;
|
||||
return Promise.reject(
|
||||
new Error("'text/html' is not a valid JavaScript MIME type"),
|
||||
);
|
||||
}
|
||||
|
||||
return originalPromiseAll(values);
|
||||
}) as typeof Promise.all);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/MIME type/)).toBeTruthy();
|
||||
expect(importAttempts).toBe(4);
|
||||
expect(warnSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not retry non-MIME import failures", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const originalPromiseAll = Promise.all.bind(Promise);
|
||||
let importAttempts = 0;
|
||||
|
||||
vi.spyOn(Promise, "all").mockImplementation(((values: Iterable<unknown>) => {
|
||||
if (isXtermImportBatch(values)) {
|
||||
importAttempts += 1;
|
||||
return Promise.reject(new Error("xterm constructor failed"));
|
||||
}
|
||||
|
||||
return originalPromiseAll(values);
|
||||
}) as typeof Promise.all);
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-xterm-init-error")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/xterm constructor failed/)).toBeTruthy();
|
||||
expect(importAttempts).toBe(1);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Invalid session auto-recovery ---
|
||||
describe("invalid session auto-recovery (FN-1021)", () => {
|
||||
it("calls replaceActiveTabSession when WebSocket reports session invalid (code 4004)", async () => {
|
||||
|
||||
Reference in New Issue
Block a user