Skip to content

Commit 0202716

Browse files
authored
🤖 fix: add timeout to workspace list post-compaction state fetching (#1250)
## Problem When the `POST_COMPACTION_CONTEXT` experiment is enabled, the `workspace.list()` API call blocks for up to **2 minutes per SSH workspace** if SSH hosts are unreachable. This causes the app to hang on "Loading workspaces..." forever. **Root cause:** `getPostCompactionState()` calls `fileExists()` which calls `runtime.stat()`, which for SSH runtimes waits on the connection pool with a 2-minute default timeout. The existing `catch` block only fires *after* the timeout expires. ## Solution Add a 3-second timeout wrapper around each `getPostCompactionState()` call in `list()`. If the timeout expires, the workspace is returned without post-compaction state (matching existing error-case behavior). This ensures app startup completes quickly even when SSH hosts are unreachable - users can work with local workspaces while SSH workspaces simply lack the post-compaction state indicator until connectivity is restored. ## Changes - `src/node/services/workspaceService.ts`: Wrap `getPostCompactionState()` calls with `Promise.race()` timeout - `src/node/services/workspaceService.test.ts`: Add test verifying timeout behavior --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_
1 parent ca2367a commit 0202716

File tree

2 files changed

+95
-4
lines changed

2 files changed

+95
-4
lines changed

‎src/node/services/workspaceService.test.ts‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, test, mock, beforeEach } from "bun:test";
1+
import { describe, expect, test, mock, beforeEach, jest } from "bun:test";
22
import { WorkspaceService } from "./workspaceService";
33
import type { Config } from "@/node/config";
44
import type { HistoryService } from "./historyService";
@@ -232,3 +232,88 @@ describe("WorkspaceService post-compaction metadata refresh", () => {
232232
expect(enriched.postCompaction?.planPath).toBe(postCompactionState.planPath);
233233
});
234234
});
235+
236+
describe("WorkspaceService.list post-compaction timeout", () => {
237+
let workspaceService: WorkspaceService;
238+
let mockConfig: Partial<Config>;
239+
240+
beforeEach(() => {
241+
const mockAIService: AIService = {
242+
isStreaming: mock(() => false),
243+
getWorkspaceMetadata: mock(() =>
244+
Promise.resolve({ success: false as const, error: "not found" })
245+
),
246+
on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
247+
return this;
248+
},
249+
off(_eventName: string | symbol, _listener: (...args: unknown[]) => void) {
250+
return this;
251+
},
252+
} as unknown as AIService;
253+
254+
const mockHistoryService: Partial<HistoryService> = {
255+
getHistory: mock(() => Promise.resolve({ success: true as const, data: [] })),
256+
appendToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })),
257+
};
258+
259+
const fakeWorkspace: FrontendWorkspaceMetadata = {
260+
id: "ssh-workspace",
261+
name: "ws",
262+
projectName: "proj",
263+
projectPath: "/tmp/proj",
264+
namedWorkspacePath: "/tmp/proj/ws",
265+
runtimeConfig: { type: "ssh", host: "unreachable-host", srcBaseDir: "/home/user/proj" },
266+
};
267+
268+
mockConfig = {
269+
srcDir: "/tmp/test",
270+
getSessionDir: mock(() => "/tmp/test/sessions"),
271+
generateStableId: mock(() => "test-id"),
272+
findWorkspace: mock(() => null),
273+
getAllWorkspaceMetadata: mock(() => Promise.resolve([fakeWorkspace])),
274+
};
275+
276+
const mockPartialService: Partial<PartialService> = {
277+
commitToHistory: mock(() => Promise.resolve({ success: true as const, data: undefined })),
278+
};
279+
280+
const mockInitStateManager: Partial<InitStateManager> = {};
281+
const mockExtensionMetadataService: Partial<ExtensionMetadataService> = {};
282+
const mockBackgroundProcessManager: Partial<BackgroundProcessManager> = {
283+
cleanup: mock(() => Promise.resolve()),
284+
};
285+
286+
workspaceService = new WorkspaceService(
287+
mockConfig as Config,
288+
mockHistoryService as HistoryService,
289+
mockPartialService as PartialService,
290+
mockAIService,
291+
mockInitStateManager as InitStateManager,
292+
mockExtensionMetadataService as ExtensionMetadataService,
293+
mockBackgroundProcessManager as BackgroundProcessManager
294+
);
295+
});
296+
297+
test("list returns quickly even when getPostCompactionState would hang (times out after 3s)", async () => {
298+
// Simulate a slow SSH connection that never resolves
299+
// eslint-disable-next-line @typescript-eslint/no-empty-function
300+
const neverResolves = new Promise<never>(() => {});
301+
const getPostCompactionStateSpy = jest.spyOn(
302+
workspaceService as unknown as { getPostCompactionState: () => Promise<unknown> },
303+
"getPostCompactionState"
304+
);
305+
getPostCompactionStateSpy.mockReturnValue(neverResolves);
306+
307+
const startTime = Date.now();
308+
const result = await workspaceService.list({ includePostCompaction: true });
309+
const elapsed = Date.now() - startTime;
310+
311+
// Should complete within ~3s timeout + buffer, not hang for 2 minutes
312+
expect(elapsed).toBeLessThan(5000);
313+
314+
// Should return workspace without post-compaction state (timeout fallback)
315+
expect(result.length).toBe(1);
316+
expect(result[0].id).toBe("ssh-workspace");
317+
expect(result[0].postCompaction).toBeUndefined();
318+
});
319+
});

‎src/node/services/workspaceService.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -747,12 +747,18 @@ export class WorkspaceService extends EventEmitter {
747747
}
748748

749749
// Fetch post-compaction state for all workspaces in parallel
750-
// Catch per-workspace errors to avoid failing the entire list if one workspace is unreachable
750+
// Use a short timeout per workspace to avoid blocking app startup if SSH is unreachable
751+
const POST_COMPACTION_TIMEOUT_MS = 3000;
751752
return Promise.all(
752753
metadata.map(async (ws) => {
753754
try {
754-
const postCompaction = await this.getPostCompactionState(ws.id);
755-
return { ...ws, postCompaction };
755+
const postCompaction = await Promise.race([
756+
this.getPostCompactionState(ws.id),
757+
new Promise<null>((resolve) =>
758+
setTimeout(() => resolve(null), POST_COMPACTION_TIMEOUT_MS)
759+
),
760+
]);
761+
return postCompaction ? { ...ws, postCompaction } : ws;
756762
} catch {
757763
// Workspace runtime unavailable (e.g., SSH unreachable) - return without post-compaction state
758764
return ws;

0 commit comments

Comments
 (0)