FN-7686: skip redundant session-list round trip on fresh terminal load
Speed up initial terminal load by short-circuiting the no-op server session list call when there are no persisted local tabs to validate. - useTerminalSessions: when readTabsFromStorage returns zero tabs, skip the listTerminalSessions HTTP call entirely and mark bootstrap ready immediately, unblocking auto-create/WebSocket connect instead of serializing behind a provably-discarded round trip - Reload-with-persisted-tabs path is unchanged and still awaits the list call since its result is decision-relevant there - Add regression tests covering the fresh-load fast path and the persisted-tabs path - Add changeset (patch) and a docs/solutions write-up of the bootstrap-list-serialized-before-auto-create issue Files changed: .changeset/fn-7686-slow-terminal-initial-load.md | 7 ++ ...bootstrap-list-serialized-before-auto-create.md | 87 ++++++++++++++++++++++ .../hooks/__tests__/useTerminalSessions.test.ts | 73 ++++++++++++++++++ .../dashboard/app/hooks/useTerminalSessions.ts | 23 +++++- 4 files changed, 189 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7686 Fusion-Task-Lineage: 9c708329-6362-4c2e-967f-aea12849c47c Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7686-slow-terminal-initial-load.md
Normal file
7
.changeset/fn-7686-slow-terminal-initial-load.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user