diff --git a/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts b/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts index 1d07dd299d8..9032188bf20 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useFileTree/useFileTree.ts @@ -40,6 +40,43 @@ interface FileTreeState { loadingDirectories: Set; } +interface LoadDirectoryOptions { + force?: boolean; +} + +function applyDirectoryEntries( + current: FileTreeState, + absolutePath: string, + entries: FsEntry[], +): FileTreeState { + const nextEntries = new Map(current.entriesByPath); + const nextChildren = new Map(current.childPathsByDirectory); + const nextLoaded = new Set(current.loadedDirectories); + const nextInvalidated = new Set(current.invalidatedDirectories); + const nextLoading = new Set(current.loadingDirectories); + nextLoading.delete(absolutePath); + nextLoaded.add(absolutePath); + nextInvalidated.delete(absolutePath); + + for (const entry of entries) { + nextEntries.set(entry.absolutePath, entry); + } + + nextChildren.set( + absolutePath, + entries.map((entry) => entry.absolutePath), + ); + + return { + ...current, + childPathsByDirectory: nextChildren, + entriesByPath: nextEntries, + invalidatedDirectories: nextInvalidated, + loadedDirectories: nextLoaded, + loadingDirectories: nextLoading, + }; +} + function createInitialState(): FileTreeState { return { childPathsByDirectory: new Map(), @@ -187,16 +224,15 @@ export function useFileTree({ ); const loadDirectory = useCallback( - async (absolutePath: string, force = false): Promise => { - if (!workspaceId || !absolutePath) { - return; - } + async ( + absolutePath: string, + options: LoadDirectoryOptions = {}, + ): Promise => { + const { force = false } = options; + if (!workspaceId || !absolutePath) return; const currentState = stateRef.current; - if (currentState.loadingDirectories.has(absolutePath)) { - return; - } - + if (currentState.loadingDirectories.has(absolutePath)) return; if ( !force && currentState.loadedDirectories.has(absolutePath) && @@ -205,65 +241,38 @@ export function useFileTree({ return; } - updateState((current) => { - const nextLoading = new Set(current.loadingDirectories); - nextLoading.add(absolutePath); - return { - ...current, - loadingDirectories: nextLoading, - }; - }); + const input = { workspaceId, absolutePath }; + const cachedResult = utils.filesystem.listDirectory.getData(input); + if (cachedResult) { + updateState((current) => + applyDirectoryEntries(current, absolutePath, cachedResult.entries), + ); + if (!force) return; + } - try { - const result = await utils.filesystem.listDirectory.fetch({ - workspaceId, + updateState((current) => ({ + ...current, + loadingDirectories: new Set(current.loadingDirectories).add( absolutePath, - }); - - updateState((current) => { - const nextEntries = new Map(current.entriesByPath); - const nextChildren = new Map(current.childPathsByDirectory); - const nextLoaded = new Set(current.loadedDirectories); - const nextInvalidated = new Set(current.invalidatedDirectories); - const nextLoading = new Set(current.loadingDirectories); - nextLoading.delete(absolutePath); - nextLoaded.add(absolutePath); - nextInvalidated.delete(absolutePath); - - for (const entry of result.entries) { - nextEntries.set(entry.absolutePath, entry); - } - - nextChildren.set( - absolutePath, - result.entries.map((entry) => entry.absolutePath), - ); + ), + })); - return { - ...current, - childPathsByDirectory: nextChildren, - entriesByPath: nextEntries, - invalidatedDirectories: nextInvalidated, - loadedDirectories: nextLoaded, - loadingDirectories: nextLoading, - }; - }); + try { + // Server-side timeout + React Query's TIMEOUT-aware retry handle + // hung host-service IPC; we just await the fetch and apply results. + const result = await utils.filesystem.listDirectory.fetch(input); + updateState((current) => + applyDirectoryEntries(current, absolutePath, result.entries), + ); } catch (error) { console.error( "[workspace-client/useFileTree] Failed to load directory:", - { - absolutePath, - error, - }, + { absolutePath, error }, ); - updateState((current) => { const nextLoading = new Set(current.loadingDirectories); nextLoading.delete(absolutePath); - return { - ...current, - loadingDirectories: nextLoading, - }; + return { ...current, loadingDirectories: nextLoading }; }); } }, @@ -272,7 +281,7 @@ export function useFileTree({ const refreshPath = useCallback( async (absolutePath: string): Promise => { - await loadDirectory(absolutePath, true); + await loadDirectory(absolutePath, { force: true }); }, [loadDirectory], ); @@ -288,10 +297,10 @@ export function useFileTree({ (left, right) => left.split(/[/\\]/).length - right.split(/[/\\]/).length, ); - await loadDirectory(rootPath, true); + await loadDirectory(rootPath, { force: true }); for (const absolutePath of expandedDirectories) { if (absolutePath !== rootPath) { - await loadDirectory(absolutePath, true); + await loadDirectory(absolutePath, { force: true }); } } }, [loadDirectory, rootPath]); @@ -347,11 +356,8 @@ export function useFileTree({ useEffect(() => { updateState(() => createInitialState()); - if (!rootPath) { - return; - } - - void loadDirectory(rootPath, true); + if (!rootPath) return; + void loadDirectory(rootPath, { force: true }); }, [loadDirectory, rootPath, updateState]); useWorkspaceEvent( @@ -404,16 +410,16 @@ export function useFileTree({ }); if (stateRef.current.loadedDirectories.has(oldParentPath)) { - void loadDirectory(oldParentPath, true); + void loadDirectory(oldParentPath, { force: true }); } if (stateRef.current.loadedDirectories.has(newParentPath)) { - void loadDirectory(newParentPath, true); + void loadDirectory(newParentPath, { force: true }); } if ( event.isDirectory && stateRef.current.expandedDirectories.has(event.absolutePath) ) { - void loadDirectory(event.absolutePath, true); + void loadDirectory(event.absolutePath, { force: true }); } return; } @@ -438,7 +444,7 @@ export function useFileTree({ }); if (stateRef.current.loadedDirectories.has(parentPath)) { - void loadDirectory(parentPath, true); + void loadDirectory(parentPath, { force: true }); } }, Boolean(workspaceId && rootPath), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx index d1d585c9ca9..5c97a8d25b7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -42,7 +42,6 @@ interface WorkspaceSidebarProps { selectedFilePath?: string; pendingReveal?: PendingReveal | null; workspaceId: string; - workspaceName?: string; } function IconButton({ @@ -80,7 +79,6 @@ export function WorkspaceSidebar({ selectedFilePath, pendingReveal, workspaceId, - workspaceName, }: WorkspaceSidebarProps) { const collections = useCollections(); const localState = collections.v2WorkspaceLocalState.get(workspaceId); @@ -142,7 +140,6 @@ export function WorkspaceSidebar({ selectedFilePath={selectedFilePath} pendingReveal={pendingReveal} workspaceId={workspaceId} - workspaceName={workspaceName} gitStatus={gitStatus.data} /> ), diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx index b90f8e315b0..5cb23450489 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/components/FilesTab/FilesTab.tsx @@ -5,7 +5,13 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { workspaceTrpc } from "@superset/workspace-client"; import type { inferRouterOutputs } from "@trpc/server"; -import { FilePlus, FolderPlus, FoldVertical, RefreshCw } from "lucide-react"; +import { + FilePlus, + FolderPlus, + FoldVertical, + Loader2, + RefreshCw, +} from "lucide-react"; import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { type FileTreeNode, @@ -39,7 +45,6 @@ interface FilesTabProps { isDirectory: boolean; } | null; workspaceId: string; - workspaceName?: string; gitStatus: GitStatusData | undefined; } @@ -209,7 +214,6 @@ export function FilesTab({ selectedFilePath, pendingReveal, workspaceId, - workspaceName, gitStatus, }: FilesTabProps) { const [_isRefreshing, setIsRefreshing] = useState(false); @@ -462,10 +466,17 @@ export function FilesTab({ [workspaceId, deletePath], ); - if (!workspaceQuery.data?.worktreePath) { + if (!rootPath) { return ( -
- Workspace worktree not available +
+ {workspaceQuery.isLoading ? ( + <> + + Loading files... + + ) : ( + "Workspace worktree not available" + )}
); } @@ -503,7 +514,7 @@ export function FilesTab({ zIndex: 20, }} > - {workspaceName ?? "Explorer"} + Explorer
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 73a0b01d2e1..ea74a380bc2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -102,7 +102,6 @@ function V2WorkspacePage() { { ... }), + +// Override when the work legitimately takes longer. +mySlowQuery: queryProcedure + .meta({ timeoutMs: 30_000 }) + .input(...) + .query(async ({ ctx, input }) => { ... }), +``` + +Pick the smallest budget that fits the slowest legitimate run on real +hardware. Too generous and the UX of a hung host-service degrades; too +tight and healthy queries time out under load. + +## Current budgets + +| Procedure | Budget | Reason | +|---|---|---| +| `filesystem.listDirectory`, `filesystem.getMetadata` | 5s | Fast in practice | +| `filesystem.readFile` | 30s | Large files (e.g. lockfiles, generated bundles) | +| `filesystem.searchFiles` | 30s | ripgrep on large repos | +| `filesystem.searchContent` | 60s | content search worst case | +| `git.listBranches`, `git.getBaseBranch`, `git.getPullRequest` | 5s | Cheap reads | +| `git.getStatus`, `git.getCommitFiles` | 15s | Slow on big working trees | +| `git.listCommits`, `git.getDiff`, `git.getBranchSyncStatus`, `git.getPullRequestThreads` | 30s | Long history, big diffs, GitHub API | + +## What the timeout does *not* do + +The middleware only races a timer against the procedure's `next()` +result. It does **not** kill the underlying work — `fs.readdir`, `git` +child processes, etc. continue server-side until they finish naturally. +For ops that *can* be cancelled, the procedure should plumb the +`AbortSignal` through. `filesystem.listDirectory` does this: +the renderer's tRPC client provides `signal` automatically (and +`abortOnUnmount: true` aborts on unmount), the procedure forwards it +to the `FsService`, and `workspace-fs/fs.ts::listDirectory` checks +`signal?.throwIfAborted()` between `fs.readdir` and each batch of stat +calls. Node's `fs.readdir`/`fs.stat` themselves ignore `AbortSignal`, +so the readdir syscall is uncancellable; we can only short-circuit +between operations. + +## What a timeout looks like to the client + +`TRPCClientError` with `error.data.code === "TIMEOUT"`. The +`WorkspaceClientProvider` retry predicate keys on this. Bespoke per-hook +retry logic should not be necessary — if it is, the procedure's budget +is probably wrong, or the underlying work isn't really a single query +and should be split. diff --git a/packages/host-service/src/trpc/index.ts b/packages/host-service/src/trpc/index.ts index a74f14b22d7..dd09c811239 100644 --- a/packages/host-service/src/trpc/index.ts +++ b/packages/host-service/src/trpc/index.ts @@ -8,41 +8,54 @@ import { type TeardownFailureCause, } from "./error-types"; -const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter({ shape, error }) { - // tRPC wraps non-Error `cause` values via getCauseFromUnknown() into a - // synthetic UnknownCauseError that carries the original fields as own - // properties. Superjson then serializes it as an Error (message/stack - // only) and drops our fields. Re-build a plain object so the wire - // format keeps `kind`, `exitCode`, `outputTail`, etc. - const teardownFailure: TeardownFailureCause | undefined = - isTeardownFailureCause(error.cause) - ? { - kind: "TEARDOWN_FAILED", - exitCode: error.cause.exitCode, - signal: error.cause.signal, - timedOut: error.cause.timedOut, - outputTail: error.cause.outputTail, - } - : undefined; - const projectNotSetup: ProjectNotSetupCause | undefined = - isProjectNotSetupCause(error.cause) - ? { - kind: "PROJECT_NOT_SETUP", - projectId: error.cause.projectId, - } - : undefined; - return { - ...shape, - data: { - ...shape.data, - teardownFailure, - projectNotSetup, - }, - }; - }, -}); +interface RouterMeta { + /** + * Per-procedure timeout in milliseconds, applied to query procedures + * via `queryProcedure`. Defaults to 5_000 when omitted. Set higher for + * procedures that legitimately take longer (e.g. searching large + * histories or shelling out to long-running commands). + */ + timeoutMs?: number; +} + +const t = initTRPC + .context() + .meta() + .create({ + transformer: superjson, + errorFormatter({ shape, error }) { + // tRPC wraps non-Error `cause` values via getCauseFromUnknown() into a + // synthetic UnknownCauseError that carries the original fields as own + // properties. Superjson then serializes it as an Error (message/stack + // only) and drops our fields. Re-build a plain object so the wire + // format keeps `kind`, `exitCode`, `outputTail`, etc. + const teardownFailure: TeardownFailureCause | undefined = + isTeardownFailureCause(error.cause) + ? { + kind: "TEARDOWN_FAILED", + exitCode: error.cause.exitCode, + signal: error.cause.signal, + timedOut: error.cause.timedOut, + outputTail: error.cause.outputTail, + } + : undefined; + const projectNotSetup: ProjectNotSetupCause | undefined = + isProjectNotSetupCause(error.cause) + ? { + kind: "PROJECT_NOT_SETUP", + projectId: error.cause.projectId, + } + : undefined; + return { + ...shape, + data: { + ...shape.data, + teardownFailure, + projectNotSetup, + }, + }; + }, + }); export const router = t.router; export const publicProcedure = t.procedure; @@ -57,6 +70,44 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { return next({ ctx }); }); +const DEFAULT_QUERY_TIMEOUT_MS = 5_000; + +const timeoutMiddleware = t.middleware(async ({ next, type, path, meta }) => { + if (type !== "query") return next(); + const timeoutMs = meta?.timeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS; + + let timer: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new TRPCError({ + code: "TIMEOUT", + message: `${path} timed out after ${timeoutMs}ms`, + }), + ); + }, timeoutMs); + }); + + try { + return await Promise.race([next(), timeoutPromise]); + } finally { + if (timer) clearTimeout(timer); + } +}); + +/** + * Query procedures with a server-side timeout. Hung filesystem/git work + * rejects after `meta.timeoutMs` (default 5s) so the renderer doesn't + * spin forever. React Query is configured to retry on `TIMEOUT` errors. + * + * Use this for `.query` procedures only — mutations have variable + * latency and shouldn't share a blanket budget. + * + * See `packages/host-service/QUERY_TIMEOUTS.md` for the policy and + * current per-procedure budgets. + */ +export const queryProcedure = protectedProcedure.use(timeoutMiddleware); + export type { ProjectNotSetupCause, TeardownFailureCause, diff --git a/packages/host-service/src/trpc/router/filesystem/filesystem.ts b/packages/host-service/src/trpc/router/filesystem/filesystem.ts index 96b41812804..ab1df1ad405 100644 --- a/packages/host-service/src/trpc/router/filesystem/filesystem.ts +++ b/packages/host-service/src/trpc/router/filesystem/filesystem.ts @@ -3,7 +3,7 @@ import { isAbsolute, join, normalize, resolve } from "node:path"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import type { HostServiceContext } from "../../../types"; -import { protectedProcedure, router } from "../../index"; +import { protectedProcedure, queryProcedure, router } from "../../index"; function getFilesystemService(ctx: HostServiceContext, workspaceId: string) { try { @@ -51,20 +51,21 @@ const writeFileContentSchema = z.union([ ]); export const filesystemRouter = router({ - listDirectory: protectedProcedure + listDirectory: queryProcedure .input( z.object({ workspaceId: z.string(), absolutePath: z.string(), }), ) - .query(async ({ ctx, input }) => { + .query(async ({ ctx, input, signal }) => { const { workspaceId, ...serviceInput } = input; const service = getFilesystemService(ctx, workspaceId); - return await service.listDirectory(serviceInput); + return await service.listDirectory(serviceInput, { signal }); }), - readFile: protectedProcedure + readFile: queryProcedure + .meta({ timeoutMs: 30_000 }) .input( z.object({ workspaceId: z.string(), @@ -89,7 +90,7 @@ export const filesystemRouter = router({ return result; }), - getMetadata: protectedProcedure + getMetadata: queryProcedure .input( z.object({ workspaceId: z.string(), @@ -248,7 +249,8 @@ export const filesystemRouter = router({ return await service.copyPath(serviceInput); }), - searchFiles: protectedProcedure + searchFiles: queryProcedure + .meta({ timeoutMs: 30_000 }) .input( z .object({ @@ -285,7 +287,8 @@ export const filesystemRouter = router({ }); }), - searchContent: protectedProcedure + searchContent: queryProcedure + .meta({ timeoutMs: 60_000 }) .input( z.object({ workspaceId: z.string(), diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 40402ef3d1d..325b61d183b 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -3,7 +3,7 @@ import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import { z } from "zod"; import { projects, pullRequests, workspaces } from "../../../db/schema"; -import { protectedProcedure, router } from "../../index"; +import { protectedProcedure, queryProcedure, router } from "../../index"; import type { ChangedFile, CheckConclusionState, @@ -34,7 +34,7 @@ import { import { resolveWorktreePath } from "./utils/resolve-worktree"; export const gitRouter = router({ - listBranches: protectedProcedure + listBranches: queryProcedure .input(z.object({ workspaceId: z.string() })) .query(async ({ ctx, input }) => { const worktreePath = resolveWorktreePath(ctx, input.workspaceId); @@ -64,7 +64,8 @@ export const gitRouter = router({ return { branches }; }), - getStatus: protectedProcedure + getStatus: queryProcedure + .meta({ timeoutMs: 15_000 }) .input( z.object({ workspaceId: z.string(), @@ -216,7 +217,8 @@ export const gitRouter = router({ }; }), - listCommits: protectedProcedure + listCommits: queryProcedure + .meta({ timeoutMs: 30_000 }) .input( z.object({ workspaceId: z.string(), @@ -253,7 +255,8 @@ export const gitRouter = router({ return { commits }; }), - getCommitFiles: protectedProcedure + getCommitFiles: queryProcedure + .meta({ timeoutMs: 15_000 }) .input( z.object({ workspaceId: z.string(), @@ -271,7 +274,7 @@ export const gitRouter = router({ return { files }; }), - getBaseBranch: protectedProcedure + getBaseBranch: queryProcedure .input(z.object({ workspaceId: z.string() })) .query(async ({ ctx, input }) => { const worktreePath = resolveWorktreePath(ctx, input.workspaceId); @@ -358,7 +361,8 @@ export const gitRouter = router({ return { name: input.newName }; }), - getDiff: protectedProcedure + getDiff: queryProcedure + .meta({ timeoutMs: 30_000 }) .input( z.object({ workspaceId: z.string(), @@ -436,7 +440,8 @@ export const gitRouter = router({ }; }), - getBranchSyncStatus: protectedProcedure + getBranchSyncStatus: queryProcedure + .meta({ timeoutMs: 30_000 }) .input(z.object({ workspaceId: z.string() })) .query(async ({ ctx, input }) => { const worktreePath = resolveWorktreePath(ctx, input.workspaceId); @@ -503,7 +508,7 @@ export const gitRouter = router({ }; }), - getPullRequest: protectedProcedure + getPullRequest: queryProcedure .input(z.object({ workspaceId: z.string() })) .query(({ ctx, input }) => { const workspace = ctx.db.query.workspaces @@ -562,7 +567,8 @@ export const gitRouter = router({ }; }), - getPullRequestThreads: protectedProcedure + getPullRequestThreads: queryProcedure + .meta({ timeoutMs: 30_000 }) .input(z.object({ workspaceId: z.string() })) .query(async ({ ctx, input }) => { const workspace = ctx.db.query.workspaces diff --git a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx index 1ad9dfb12c4..3e359b6a871 100644 --- a/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx +++ b/packages/workspace-client/src/providers/WorkspaceClientProvider/WorkspaceClientProvider.tsx @@ -1,11 +1,17 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchStreamLink } from "@trpc/client"; +import { httpBatchStreamLink, TRPCClientError } from "@trpc/client"; import { createContext, type ReactNode, useContext } from "react"; import superjson from "superjson"; import { workspaceTrpc } from "../../workspace-trpc"; const STALE_TIME_MS = 5_000; const GC_TIME_MS = 30 * 60 * 1_000; +const MAX_TIMEOUT_RETRIES = 2; +const TIMEOUT_RETRY_BASE_DELAY_MS = 300; + +function isTimeoutError(error: unknown): boolean { + return error instanceof TRPCClientError && error.data?.code === "TIMEOUT"; +} export interface WorkspaceClientContextValue { hostUrl: string; @@ -49,7 +55,18 @@ function getWorkspaceClients( defaultOptions: { queries: { refetchOnWindowFocus: false, - retry: 1, + // Retry server-side TIMEOUT errors a couple of times — these come + // from `queryProcedure`'s middleware when a host-service query + // (filesystem, git) takes longer than its budget. Other errors + // fall back to a single retry as before. + retry: (failureCount, error) => { + if (isTimeoutError(error)) return failureCount < MAX_TIMEOUT_RETRIES; + return failureCount < 1; + }, + retryDelay: (attempt, error) => + isTimeoutError(error) + ? TIMEOUT_RETRY_BASE_DELAY_MS * (attempt + 1) + : Math.min(1000 * 2 ** attempt, 30_000), staleTime: STALE_TIME_MS, gcTime: GC_TIME_MS, }, diff --git a/packages/workspace-client/src/workspace-trpc.ts b/packages/workspace-client/src/workspace-trpc.ts index d601513a5c2..6d38436c15f 100644 --- a/packages/workspace-client/src/workspace-trpc.ts +++ b/packages/workspace-client/src/workspace-trpc.ts @@ -1,4 +1,6 @@ import type { AppRouter } from "@superset/host-service/trpc"; import { createTRPCReact } from "@trpc/react-query"; -export const workspaceTrpc = createTRPCReact(); +export const workspaceTrpc = createTRPCReact({ + abortOnUnmount: true, +}); diff --git a/packages/workspace-fs/src/core/service.ts b/packages/workspace-fs/src/core/service.ts index 787f9948d22..5f75713c735 100644 --- a/packages/workspace-fs/src/core/service.ts +++ b/packages/workspace-fs/src/core/service.ts @@ -9,9 +9,10 @@ import type { } from "../types"; export interface FsService { - listDirectory(input: { - absolutePath: string; - }): Promise<{ entries: FsEntry[] }>; + listDirectory( + input: { absolutePath: string }, + options?: { signal?: AbortSignal }, + ): Promise<{ entries: FsEntry[] }>; readFile(input: { absolutePath: string; diff --git a/packages/workspace-fs/src/fs.ts b/packages/workspace-fs/src/fs.ts index a72073b2c67..4f71767e5ae 100644 --- a/packages/workspace-fs/src/fs.ts +++ b/packages/workspace-fs/src/fs.ts @@ -368,36 +368,51 @@ async function writeAtomically({ } } +// Symlink-resolution batch size. Node's fs.readdir and fs.stat ignore +// AbortSignal, so we can only check it between operations — batching the +// per-entry stat calls bounds how much zombie work continues after an abort. +const LIST_DIRECTORY_STAT_BATCH_SIZE = 16; + export async function listDirectory({ rootPath, absolutePath, + signal, }: { rootPath: string; absolutePath: string; + signal?: AbortSignal; }): Promise { const targetPath = ensureWithinRoot({ rootPath, absolutePath }); + signal?.throwIfAborted(); const entries = await fs.readdir(targetPath, { withFileTypes: true }); - const mapped = await Promise.all( - entries.map(async (entry) => { - let kind = direntToKind(entry); - // Resolve symlinks to determine target type (e.g. symlinked dirs in node_modules) - if (kind === "symlink") { - try { - const stats = await fs.stat(path.join(targetPath, entry.name)); - if (stats.isDirectory()) kind = "directory"; - else if (stats.isFile()) kind = "file"; - } catch { - // Dangling symlink or permission error — keep as "symlink" - } - } - return { - absolutePath: path.join(targetPath, entry.name), - name: entry.name, - kind, - }; - }), - ); + const mapped: FsEntry[] = []; + for (let i = 0; i < entries.length; i += LIST_DIRECTORY_STAT_BATCH_SIZE) { + signal?.throwIfAborted(); + const batch = await Promise.all( + entries + .slice(i, i + LIST_DIRECTORY_STAT_BATCH_SIZE) + .map(async (entry) => { + let kind = direntToKind(entry); + // Resolve symlinks to determine target type (e.g. symlinked dirs in node_modules) + if (kind === "symlink") { + try { + const stats = await fs.stat(path.join(targetPath, entry.name)); + if (stats.isDirectory()) kind = "directory"; + else if (stats.isFile()) kind = "file"; + } catch { + // Dangling symlink or permission error — keep as "symlink" + } + } + return { + absolutePath: path.join(targetPath, entry.name), + name: entry.name, + kind, + }; + }), + ); + mapped.push(...batch); + } return mapped.sort((left, right) => { const leftIsDir = left.kind === "directory"; diff --git a/packages/workspace-fs/src/host/service.ts b/packages/workspace-fs/src/host/service.ts index 973be99bc72..b96ee33feca 100644 --- a/packages/workspace-fs/src/host/service.ts +++ b/packages/workspace-fs/src/host/service.ts @@ -136,10 +136,11 @@ export function createFsHostService( const { rootPath } = options; return { - async listDirectory(input) { + async listDirectory(input, options) { const entries = await listDirectory({ rootPath, absolutePath: input.absolutePath, + signal: options?.signal, }); return { entries }; },