diff --git a/.changeset/fn-7686-slow-terminal-initial-load.md b/.changeset/fn-7686-slow-terminal-initial-load.md new file mode 100644 index 0000000000..a8de65f2e0 --- /dev/null +++ b/.changeset/fn-7686-slow-terminal-initial-load.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix dashboard terminal showing a blank screen for seconds before the first prompt appears on open. +category: performance +dev: `useTerminalSessions` no longer awaits a discardable `listTerminalSessions()` round trip before auto-creating the first session when there are no persisted `kb-terminal-tabs`; the round trip only produced a no-op filter result in that case. Reload-with-persisted-tabs is unaffected — it still awaits session-list validation. diff --git a/docs/solutions/logic-errors/terminal-bootstrap-list-serialized-before-auto-create.md b/docs/solutions/logic-errors/terminal-bootstrap-list-serialized-before-auto-create.md new file mode 100644 index 0000000000..a1ed4676e6 --- /dev/null +++ b/docs/solutions/logic-errors/terminal-bootstrap-list-serialized-before-auto-create.md @@ -0,0 +1,87 @@ +--- +category: logic-errors +module: dashboard-terminal +tags: [terminal, bootstrap, latency, websocket, xterm, performance] +problem_type: unnecessary-serialization +applies_when: A client bootstrap sequence gates a decision-irrelevant network round trip in front of the round trip that actually creates the resource the user is waiting on. +--- + +# Terminal initial load blocked by a no-op session-list round trip (FN-7686) + +## Problem + +Opening the dashboard terminal rendered the terminal chrome immediately but +the xterm surface stayed blank for seconds before the first shell +prompt/output appeared. The delay lived in the bootstrap sequencing, not in +xterm rendering or steady-state I/O. + +`useTerminalSessions.ts`'s `validateAndRestore()` effect always called +`listTerminalSessions()` (bounded by `BOOTSTRAP_LIST_TIMEOUT_MS = 15000`) and +did not set `isReady=true` — the gate the auto-create effect waits on — +until that HTTP round trip resolved. On a **fresh open** (no persisted +`kb-terminal-tabs`), that round trip's result is provably discarded: with +zero local tabs, the stale-session filter always reduces to an empty array +regardless of what the server returns. The list call was validation in +name only, yet it fully serialized in front of `createTerminalSession()` — +the round trip that actually spawns the PTY session the terminal needs +before a WebSocket can even attempt to connect. + +## Root cause pattern + +A bootstrap step (`list`) is unconditionally awaited before a dependent step +(`create`), even when the first step's result cannot change the second +step's outcome for a specific precondition (here: nothing to validate). +This is the general "unnecessary round-trip serialization" trap — the code +reads as "validate then create" but for one common branch, "validate" does +nothing. + +## Fix + +Skip the list-validation round trip entirely when +`readTabsFromStorage(projectId)` is empty on mount, and mark +`serverAvailable=true`/`isReady=true` immediately so the auto-create effect +is not gated behind a discardable network call. Leave the list call fully +in place whenever there ARE persisted tabs to validate (that case IS +decision-relevant — the server tells you which sessionIds still exist). + +```ts +// packages/dashboard/app/hooks/useTerminalSessions.ts +if (readTabsFromStorage(projectId).length === 0) { + setServerAvailable(true); + setIsReady(true); + return; // skip listTerminalSessions() — nothing to validate +} +``` + +## Guardrails preserved + +- `generationRef` staleness guards against stale bootstrap completions. +- Windows no-auto-create suppression (unchanged — still runs before this + branch). +- `bootstrapCreateInFlightGenerationRef` create-dedup and the + `setTimeout(…, 0)` micro-guard. +- The 15s `BOOTSTRAP_LIST_TIMEOUT_MS`/`BOOTSTRAP_CREATE_TIMEOUT_MS` bounds + (unchanged for the reload-with-persisted-tabs path). +- `--login` shell profile execution (left untouched — real, + environment-dependent cost, not "fixed" by dropping shell setup). + +## What was ruled out with evidence (do not re-attempt without new evidence) + +- xterm dynamic import/init (`TerminalModal.initTerminal`) — already runs + concurrently with session bootstrap; not serialized. +- `READY_QUIET_WINDOW_MS`/resize-suppression buffering — bounded at 150ms. +- `resolveScopedStore` (multi-project) — resolves synchronously from an + already-live engine on the warm-project path. +- WebSocket connect gated behind session creation — inherent and correct; + a session must exist before its socket can open. + +## Regression command + +```bash +pnpm --filter @fusion/dashboard exec vitest run app/hooks/__tests__/useTerminalSessions.test.ts app/hooks/__tests__/useTerminal.test.ts app/components/__tests__/TerminalModal.test.tsx --silent=passed-only --reporter=dot +``` + +The key regression (`bootstrap sequencing (FN-7686)` describe block in +`useTerminalSessions.test.ts`) holds `listTerminalSessions()` permanently +pending on a fresh (empty-localStorage) mount and asserts auto-create still +completes — this fails pre-fix (timeout) and passes post-fix. diff --git a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts index 1c8b809d93..d034b69c83 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts @@ -212,6 +212,79 @@ describe("useTerminalSessions", () => { }); }); + describe("bootstrap sequencing (FN-7686)", () => { + it("does not serialize auto-create behind a never-resolving session list on a fresh open", async () => { + // FNXC:Terminal 2026-07-08-10:00: + // Regression for FN-7686: on a fresh open (no persisted kb-terminal-tabs), + // the list-validation round trip has nothing to validate (there are no + // local tabs), so it must not block auto-create. This test holds + // listTerminalSessions() permanently pending to prove the auto-create + // path does not wait on it — asserting observable sequencing, not just + // that createTerminalSession was eventually called. + localStorageMock.getItem.mockReturnValue(null); + mockListTerminalSessions.mockReturnValue(new Promise(() => {})); // never resolves + mockCreateTerminalSession.mockResolvedValue({ + sessionId: "session-fast", + shell: "/bin/bash", + cwd: "/project", + }); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + // Auto-create must complete even though listTerminalSessions never settles. + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + expect(result.current.activeTab?.sessionId).toBe("session-fast"); + expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1); + }); + + it("still awaits session-list validation before auto-create when tabs are persisted", async () => { + // Reload-with-persisted-tabs case: list validation IS decision-relevant + // (must know which sessionIds still exist), so auto-create must remain + // gated behind it. This guards against an overly-broad fix that skips + // validation unconditionally. + const storedTabs = [ + { + id: "tab-1", + sessionId: "session-stale", + title: "bash", + isActive: true, + createdAt: Date.now(), + }, + ]; + localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs)); + let resolveList: (val: unknown[]) => void; + mockListTerminalSessions.mockReturnValue( + new Promise((resolve) => { + resolveList = resolve; + }), + ); + mockCreateTerminalSession.mockResolvedValue({ + sessionId: "session-new", + shell: "/bin/bash", + cwd: "/project", + }); + + renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + // Give pending microtasks a chance to flush; auto-create must NOT have + // fired yet because list validation (decision-relevant here) is still pending. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(mockCreateTerminalSession).not.toHaveBeenCalled(); + + // Resolve the list call with no matching sessions (stale tab) — now + // auto-create should proceed. + await act(async () => { + resolveList!([]); + }); + + await waitFor(() => { + expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1); + }); + }); + }); + describe("creating additional tabs", () => { it("creates new tab with fresh session when createTab is called", async () => { localStorageMock.getItem.mockReturnValue(null); diff --git a/packages/dashboard/app/hooks/useTerminalSessions.ts b/packages/dashboard/app/hooks/useTerminalSessions.ts index db874a83df..8ca9d0818b 100644 --- a/packages/dashboard/app/hooks/useTerminalSessions.ts +++ b/packages/dashboard/app/hooks/useTerminalSessions.ts @@ -172,7 +172,28 @@ export function useTerminalSessions(projectId?: string): UseTerminalSessionsRetu const validateAndRestore = async () => { if (cancelled) return; - + + /* + FNXC:Terminal 2026-07-08-10:00: + FN-7686: initial terminal load was slow because a fresh open (no + persisted kb-terminal-tabs) still paid for a full listTerminalSessions + HTTP round trip before auto-create could even begin, even though that + round trip's result is provably discarded when there are zero local + tabs to validate (remainingTabs is always [] regardless of what the + server returns). Fixed by skipping the list round trip entirely in + that case and marking bootstrap ready immediately, so auto-create (and + therefore the WebSocket connect that depends on it) is not serialized + behind a no-op validation call. Reload-with-persisted-tabs still awaits + the list call below, since its result IS decision-relevant there (which + sessionIds still exist server-side). + */ + if (readTabsFromStorage(projectId).length === 0) { + if (cancelled || gen !== generationRef.current) return; + setServerAvailable(true); + setIsReady(true); + return; + } + try { // Get active server sessions with bounded timeout const serverSessions = await withTimeout(