diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/inFlightGuard.test.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/inFlightGuard.test.ts new file mode 100644 index 00000000000..257ed78aa4c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/inFlightGuard.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { createInFlightGuard } from "./inFlightGuard"; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("createInFlightGuard", () => { + test("reproduces #4384: without a guard, rapid Cmd+T presses all spawn a terminal once the daemon bootstrap unblocks", async () => { + // Simulates the cold-start daemon bootstrap that blocks + // `terminal.createSession`. Every call sees the same pending promise + // and unblocks together when it resolves. + const bootstrap = deferred(); + let terminalsCreated = 0; + const createTerminal = async () => { + await bootstrap.promise; + terminalsCreated += 1; + }; + + // User mashes Cmd+T five times during the bootstrap window. + const presses = Array.from({ length: 5 }, () => createTerminal()); + + bootstrap.resolve(); + await Promise.all(presses); + + // Without an in-flight guard, every queued press creates a terminal. + expect(terminalsCreated).toBe(5); + }); + + test("with the guard, rapid presses while a creation is in flight are dropped", async () => { + const bootstrap = deferred(); + let terminalsCreated = 0; + const guard = createInFlightGuard(); + + const press = () => + guard.run(async () => { + await bootstrap.promise; + terminalsCreated += 1; + }); + + const presses = Array.from({ length: 5 }, () => press()); + + bootstrap.resolve(); + await Promise.all(presses); + + expect(terminalsCreated).toBe(1); + }); + + test("releases the lock so subsequent presses succeed once the prior call settles", async () => { + const guard = createInFlightGuard(); + let terminalsCreated = 0; + const press = () => + guard.run(async () => { + terminalsCreated += 1; + }); + + await press(); + await press(); + await press(); + + expect(terminalsCreated).toBe(3); + }); + + test("releases the lock when the wrapped function rejects", async () => { + const guard = createInFlightGuard(); + let attempts = 0; + + await expect( + guard.run(async () => { + attempts += 1; + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + + await guard.run(async () => { + attempts += 1; + }); + + expect(attempts).toBe(2); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/inFlightGuard.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/inFlightGuard.ts new file mode 100644 index 00000000000..1c9985c1db5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2TerminalLauncher/inFlightGuard.ts @@ -0,0 +1,22 @@ +/** + * Drops re-entrant calls while a prior invocation is still in flight. Used + * to coalesce rapid Cmd+T / "New Terminal" presses during the cold-start + * daemon bootstrap so they don't all spawn a terminal once the bootstrap + * unblocks. + */ +export function createInFlightGuard(): { + run: (fn: () => Promise) => Promise; +} { + let inFlight = false; + return { + async run(fn) { + if (inFlight) return; + inFlight = true; + try { + await fn(); + } finally { + inFlight = false; + } + }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts index 9e0cf6cd125..1d101f411b1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspaceHotkeys/useWorkspaceHotkeys.ts @@ -18,6 +18,7 @@ import type { TerminalPaneData, } from "../../types"; import type { TerminalLauncher } from "../useV2TerminalLauncher"; +import { createInFlightGuard } from "../useV2TerminalLauncher/inFlightGuard"; export function useWorkspaceHotkeys({ store, @@ -44,17 +45,23 @@ export function useWorkspaceHotkeys({ // --- Tab creation --- - useHotkey("NEW_GROUP", async () => { - const terminalId = await launcher.create(); - store.getState().addTab({ - panes: [ - { - kind: "terminal", - data: { terminalId } as TerminalPaneData, - }, - ], - }); - }); + // Drop repeats while a creation is in flight so rapid Cmd+T presses + // during the cold-start daemon bootstrap don't all spawn a terminal once + // it unblocks (#4384). + const newGroupGuardRef = useRef(createInFlightGuard()); + useHotkey("NEW_GROUP", () => + newGroupGuardRef.current.run(async () => { + const terminalId = await launcher.create(); + store.getState().addTab({ + panes: [ + { + kind: "terminal", + data: { terminalId } as TerminalPaneData, + }, + ], + }); + }), + ); useHotkey("NEW_CHAT", () => { store.getState().addTab({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts index 863525ab365..3e5e8400929 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useWorkspacePaneOpeners/useWorkspacePaneOpeners.ts @@ -1,5 +1,5 @@ import type { WorkspaceStore } from "@superset/panes"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import type { StoreApi } from "zustand/vanilla"; import type { BrowserPaneData, @@ -10,6 +10,7 @@ import type { TerminalPaneData, } from "../../types"; import type { TerminalLauncher } from "../useV2TerminalLauncher"; +import { createInFlightGuard } from "../useV2TerminalLauncher/inFlightGuard"; export function useWorkspacePaneOpeners({ store, @@ -93,17 +94,25 @@ export function useWorkspacePaneOpeners({ [store], ); - const addTerminalTab = useCallback(async () => { - const terminalId = await launcher.create(); - store.getState().addTab({ - panes: [ - { - kind: "terminal", - data: { terminalId } as TerminalPaneData, - }, - ], - }); - }, [store, launcher]); + // Drop rapid `addTerminalTab` invocations while one is pending so users + // who mash Cmd+T / "New Terminal" during the cold-start daemon bootstrap + // don't queue up several creations that all unblock together (#4384). + const addTerminalGuardRef = useRef(createInFlightGuard()); + const addTerminalTab = useCallback( + () => + addTerminalGuardRef.current.run(async () => { + const terminalId = await launcher.create(); + store.getState().addTab({ + panes: [ + { + kind: "terminal", + data: { terminalId } as TerminalPaneData, + }, + ], + }); + }), + [store, launcher], + ); const addChatTab = useCallback(() => { store.getState().addTab({