FN-8302: fix terminal bootstrap after stale session creation
Ensure first terminal-tab creation retries when an invalidated bootstrap request settles. - Wake the current bootstrap generation after stale session creation outcomes. - Cover scope changes that invalidate an in-flight initial tab request. Files changed: .../hooks/__tests__/useTerminalSessions.test.ts | 45 ++++++++++++++++++++++ .../dashboard/app/hooks/useTerminalSessions.ts | 33 +++++++++++++--- 2 files changed, 73 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8302 Fusion-Task-Lineage: a1d9a6f9-85eb-4168-af7f-5efc2828e3f2 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { StrictMode } from "react";
|
||||
import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { useTerminalSessions } from "../useTerminalSessions";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
@@ -71,6 +72,50 @@ describe("useTerminalSessions", () => {
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalledWith(undefined, undefined, undefined, TEST_PROJECT_ID);
|
||||
});
|
||||
|
||||
it("converges when a scope change invalidates an in-flight first-tab creation", async () => {
|
||||
let resolveFirstCreate: (session: { sessionId: string; shell: string; cwd: string }) => void;
|
||||
mockCreateTerminalSession
|
||||
.mockImplementationOnce(
|
||||
() => new Promise((resolve) => {
|
||||
resolveFirstCreate = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: "session-current-generation",
|
||||
shell: "/bin/bash",
|
||||
cwd: "/project/.worktrees/FN-8302",
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ storageScope }: { storageScope?: string }) =>
|
||||
useTerminalSessions(TEST_PROJECT_ID, { storageScope }),
|
||||
{ initialProps: { storageScope: undefined }, wrapper: StrictMode },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender({ storageScope: "task:FN-8302" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstCreate!({
|
||||
sessionId: "session-stale-generation",
|
||||
shell: "/bin/bash",
|
||||
cwd: "/project",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTab?.sessionId).toBe("session-current-generation");
|
||||
expect(result.current.bootstrapError).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-creates first scoped tab in defaultCwd with a basename title", async () => {
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
mockListTerminalSessions.mockResolvedValue([]);
|
||||
|
||||
@@ -163,6 +163,8 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
const [bootstrapError, setBootstrapError] = useState<string | null>(null);
|
||||
// Generation counter bumped by retryBootstrap to re-trigger auto-create effect
|
||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||
// FNXC:Terminal 2026-07-15-10:40: Forces auto-create to reconsider the current generation after a stale attempt settles.
|
||||
const [bootstrapWakeGeneration, setBootstrapWakeGeneration] = useState(0);
|
||||
// Ref-based generation token to protect against stale completions from prior
|
||||
// bootstrap attempts. Only the current generation may mutate state.
|
||||
const generationRef = useRef(0);
|
||||
@@ -170,6 +172,12 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
|
||||
useEffect(() => {
|
||||
generationRef.current += 1;
|
||||
// FNXC:Terminal 2026-07-15-10:40:
|
||||
// FN-8302 requires first-tab bootstrap to converge to an active tab or an
|
||||
// actionable error. A reset can invalidate an already-started create, so
|
||||
// wake the auto-create effect for the new generation instead of letting a
|
||||
// stale completion leave TerminalModal on "Starting terminal..." forever.
|
||||
setBootstrapWakeGeneration((generation) => generation + 1);
|
||||
setTabs(readTabsFromStorage(projectId, storageScope));
|
||||
setIsReady(false);
|
||||
setServerAvailable(true);
|
||||
@@ -309,8 +317,11 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
"createTerminalSession"
|
||||
)
|
||||
.then((session) => {
|
||||
// Only apply state changes if this is still the current generation
|
||||
if (gen !== generationRef.current) return;
|
||||
// FNXC:Terminal 2026-07-15-10:40: A stale completion cannot mutate tabs, but must wake the active generation so its empty bootstrap state retries deterministically.
|
||||
if (gen !== generationRef.current) {
|
||||
setBootstrapWakeGeneration((generation) => generation + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const newTab: TerminalTab = {
|
||||
id: generateTabId(),
|
||||
@@ -333,8 +344,11 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
setBootstrapError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Only set error if this is still the current generation
|
||||
if (gen !== generationRef.current) return;
|
||||
// FNXC:Terminal 2026-07-15-10:40: A stale failure cannot set an error, but must wake the current empty generation to preserve tab-or-error convergence.
|
||||
if (gen !== generationRef.current) {
|
||||
setBootstrapWakeGeneration((generation) => generation + 1);
|
||||
return;
|
||||
}
|
||||
if (!isRelativeUrlFetchError(err)) {
|
||||
console.error(err);
|
||||
}
|
||||
@@ -350,7 +364,16 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [bootstrapError, defaultCwd, isReady, projectId, serverAvailable, tabs.length, retryGeneration]); // Run when ready or when tabs become empty
|
||||
}, [
|
||||
bootstrapError,
|
||||
bootstrapWakeGeneration,
|
||||
defaultCwd,
|
||||
isReady,
|
||||
projectId,
|
||||
serverAvailable,
|
||||
tabs.length,
|
||||
retryGeneration,
|
||||
]); // Run when ready, when tabs become empty, or after a stale attempt settles
|
||||
|
||||
/**
|
||||
* Internal create tab function (used for auto-creation and user-initiated creation).
|
||||
|
||||
Reference in New Issue
Block a user