From ed65ba7cda3399e283585e4609e3eab496aa4443 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 11:41:59 -0700 Subject: [PATCH 1/6] fix(selectors): debounce the provider search, and keep the list while it refetches --- .../components/selector-combobox/selector-combobox.tsx | 10 +++++++++- apps/sim/hooks/selectors/use-selector-query.ts | 6 +++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx index 920177208e4..fb7381a057b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Combobox as EditableCombobox } from '@sim/emcn' import { X } from '@sim/emcn/icons' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { SubBlockInputController } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/sub-block-input-controller' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' @@ -14,6 +15,7 @@ import { useSelectorOptionMap, useSelectorOptions, } from '@/hooks/selectors/use-selector-query' +import { useDebounce } from '@/hooks/use-debounce' interface SelectorComboboxProps { blockId: string @@ -63,6 +65,12 @@ export function SelectorCombobox({ const [searchTerm, setSearchTerm] = useState('') const [isEditing, setIsEditing] = useState(false) const [multiInput, setMultiInput] = useState('') + /** + * The search reaches the provider, so it is debounced before it enters the query key + * rather than on every keystroke — several of these selectors are rate-limited by the + * provider. Only the query sees the debounced value; the input stays on `searchTerm`. + */ + const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) const { data: options = [], isLoading, @@ -70,7 +78,7 @@ export function SelectorCombobox({ error, } = useSelectorOptions(selectorKey, { context: selectorContext, - search: allowSearch ? searchTerm : undefined, + search: allowSearch ? debouncedSearch : undefined, }) const { data: detailOption } = useSelectorOptionDetail(selectorKey, { context: selectorContext, diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 213afcdab91..57e47666f4f 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' -import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query' +import { keepPreviousData, useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query' import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' import { usePersonalEnvironment } from '@/hooks/queries/environment' import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry' @@ -84,6 +84,8 @@ export function useSelectorOptions( definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]), enabled: !supportsPagination && isEnabled, staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME, + /** `search` is part of the key, so without this the open dropdown empties on every edit. */ + placeholderData: keepPreviousData, }) const pagedQuery = useInfiniteQuery({ @@ -100,6 +102,8 @@ export function useSelectorOptions( initialPageParam: undefined as string | undefined, enabled: supportsPagination && isEnabled, staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME, + /** Same reason as the flat query: the key carries `search`. */ + placeholderData: keepPreviousData, }) const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery From d4b5e911711e33af7972f345439a0daa4d309d88 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 11:41:59 -0700 Subject: [PATCH 2/6] fix(tables): snapshot row pages from the non-collidable prefix --- apps/sim/hooks/queries/tables.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index bbc3718fd2f..21c3c70ec49 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -1015,12 +1015,12 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) }) }, onMutate: async ({ rowId, data }) => { - await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + await queryClient.cancelQueries({ queryKey: tableKeys.infiniteRowsRoot(tableId) }) const previousQueries = queryClient.getQueriesData< InfiniteData >({ - queryKey: tableKeys.rowsRoot(tableId), + queryKey: tableKeys.infiniteRowsRoot(tableId), }) const groups = @@ -1105,12 +1105,12 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon }) }, onMutate: async ({ updates }) => { - await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + await queryClient.cancelQueries({ queryKey: tableKeys.infiniteRowsRoot(tableId) }) const previousQueries = queryClient.getQueriesData< InfiniteData >({ - queryKey: tableKeys.rowsRoot(tableId), + queryKey: tableKeys.infiniteRowsRoot(tableId), }) const updateMap = new Map(updates.map((u) => [u.rowId, u.data])) From 9519d82ef1dbab02e4bf97d6cf1f4cc29c154f74 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 11:42:00 -0700 Subject: [PATCH 3/6] fix(vertex): resolve the OAuth token through the app, which holds the client config --- .../executor/handlers/agent/agent-handler.ts | 1 + .../evaluator/evaluator-handler.test.ts | 4 + .../handlers/evaluator/evaluator-handler.ts | 1 + .../handlers/router/router-handler.test.ts | 4 + .../handlers/router/router-handler.ts | 2 + apps/sim/executor/utils/credential-token.ts | 67 +++++++++++++++++ .../executor/utils/vertex-credential.test.ts | 73 +++++++++++++++++-- apps/sim/executor/utils/vertex-credential.ts | 28 ++++--- 8 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 apps/sim/executor/utils/credential-token.ts diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index d66a080d412..9256c3e67d3 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -2543,6 +2543,7 @@ export class AgentBlockHandler implements BlockHandler { credentialId: providerRequest.vertexCredential, actingUserId: ctx.userId, workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, callerLabel: 'vertex-agent', }) } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index 6df5974aefa..ca9958d802a 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -15,6 +15,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) +vi.mock('@/executor/utils/credential-token', () => ({ + fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'), +})) + vi.mock('@/lib/credentials/access', () => ({ canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) => access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin), diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index f162ae25c38..52f0859f88a 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -182,6 +182,7 @@ export class EvaluatorBlockHandler implements BlockHandler { credentialId: evaluatorConfig.vertexCredential, actingUserId: ctx.userId, workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, callerLabel: 'vertex-evaluator', }) } diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 8c7f7b0e945..dbbdf90f2b1 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -21,6 +21,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock) vi.mock('@/lib/core/security/encryption', () => encryptionMock) +vi.mock('@/executor/utils/credential-token', () => ({ + fetchCredentialAccessToken: vi.fn().mockResolvedValue('mock-access-token'), +})) + vi.mock('@/lib/credentials/access', () => ({ canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) => access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin), diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 3292e2c57c2..613ff0ce239 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -117,6 +117,7 @@ export class RouterBlockHandler implements BlockHandler { credentialId: routerConfig.vertexCredential, actingUserId: ctx.userId, workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, callerLabel: 'vertex-router', }) } @@ -279,6 +280,7 @@ export class RouterBlockHandler implements BlockHandler { credentialId: routerConfig.vertexCredential, actingUserId: ctx.userId, workspaceId: ctx.workspaceId, + workflowId: ctx.workflowId, callerLabel: 'vertex-router', }) } diff --git a/apps/sim/executor/utils/credential-token.ts b/apps/sim/executor/utils/credential-token.ts new file mode 100644 index 00000000000..db01918fd62 --- /dev/null +++ b/apps/sim/executor/utils/credential-token.ts @@ -0,0 +1,67 @@ +import { createLogger } from '@sim/logger' +import { generateInternalToken } from '@/lib/auth/internal' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' + +const logger = createLogger('ExecutorCredentialToken') + +/** + * Fetches a credential's access token from the app rather than resolving it here. + * + * Refreshing an OAuth token needs the provider's client id and secret, read through + * `requireOAuthClientCapability`, which THROWS when they are absent. Only the app + * container loads those (from `SIM_ENV_SECRET_ID`); workflow execution runs in a + * Trigger.dev worker whose environment does not carry them. Resolving in-process there + * turns every credential whose access token has expired into a refresh failure, and a + * still-valid token hides it until the token lapses. + * + * See `.claude/rules/sim-architecture.md`, "The app/worker runtime boundary". + * + * The route authorizes the credential itself, so this never widens access. + */ +export async function fetchCredentialAccessToken(params: { + requestId: string + credentialId: string + userId: string + workflowId?: string +}): Promise { + const { requestId, credentialId, userId, workflowId } = params + + const url = new URL('/api/auth/oauth/token', getInternalApiBaseUrl()) + if (workflowId) url.searchParams.set('workflowId', workflowId) + + const headers: Record = { 'Content-Type': 'application/json' } + try { + headers.Authorization = `Bearer ${await generateInternalToken(userId)}` + } catch (_e) { + // Swallow mint errors; the request then fails authentication and reports upstream. + } + + // boundary-raw-fetch: same-origin token route, authenticated by the internal JWT minted above + const response = await fetch(url.toString(), { + method: 'POST', + headers, + body: JSON.stringify({ credentialId, ...(workflowId ? { workflowId } : {}) }), + }) + + if (!response.ok) { + const errorText = await response.text() + logger.error(`[${requestId}] Credential token request failed`, { + status: response.status, + credentialId, + }) + let message = errorText + try { + const parsed = JSON.parse(errorText) + if (parsed.error) message = parsed.error + } catch { + // Use raw text + } + throw new Error(message) + } + + const { accessToken } = (await response.json()) as { accessToken?: string } + if (!accessToken) { + throw new Error('Credential token response carried no access token') + } + return accessToken +} diff --git a/apps/sim/executor/utils/vertex-credential.test.ts b/apps/sim/executor/utils/vertex-credential.test.ts index 78454621bbf..cea1632a235 100644 --- a/apps/sim/executor/utils/vertex-credential.test.ts +++ b/apps/sim/executor/utils/vertex-credential.test.ts @@ -3,12 +3,17 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTokenIfNeeded } = - vi.hoisted(() => ({ - mockGetCredentialActorContext: vi.fn(), - mockGetServiceAccountToken: vi.fn(), - mockRefreshTokenIfNeeded: vi.fn(), - })) +const { + mockGetCredentialActorContext, + mockGetServiceAccountToken, + mockRefreshTokenIfNeeded, + mockFetchCredentialAccessToken, +} = vi.hoisted(() => ({ + mockGetCredentialActorContext: vi.fn(), + mockGetServiceAccountToken: vi.fn(), + mockRefreshTokenIfNeeded: vi.fn(), + mockFetchCredentialAccessToken: vi.fn(), +})) vi.mock('@/lib/credentials/access', () => ({ getCredentialActorContext: mockGetCredentialActorContext, @@ -19,6 +24,9 @@ vi.mock('@/lib/oauth/credential-service', () => ({ getServiceAccountToken: mockGetServiceAccountToken, refreshTokenIfNeeded: mockRefreshTokenIfNeeded, })) +vi.mock('@/executor/utils/credential-token', () => ({ + fetchCredentialAccessToken: mockFetchCredentialAccessToken, +})) import { resolveVertexCredential } from '@/executor/utils/vertex-credential' @@ -95,3 +103,56 @@ describe('resolveVertexCredential workspace binding', () => { ).rejects.toThrow('requires an authenticated user') }) }) + +/** + * This resolver runs inside the Trigger.dev worker, whose environment carries no OAuth + * client config — an in-process refresh throws there once the stored token expires. + */ +describe('resolveVertexCredential OAuth branch', () => { + const oauthContext = { + credential: { id: 'cred-o', workspaceId: 'workspace-a', type: 'oauth', accountId: 'acct-1' }, + member: { id: 'member-1' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: false, + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetCredentialActorContext.mockResolvedValue(oauthContext) + mockFetchCredentialAccessToken.mockResolvedValue('oauth-access-token') + }) + + it('fetches the token from the app instead of refreshing in-process', async () => { + await expect( + resolveVertexCredential({ + credentialId: 'cred-o', + actingUserId: 'user-1', + workspaceId: 'workspace-a', + workflowId: 'wf-1', + }) + ).resolves.toBe('oauth-access-token') + + expect(mockRefreshTokenIfNeeded).not.toHaveBeenCalled() + expect(mockFetchCredentialAccessToken).toHaveBeenCalledWith( + expect.objectContaining({ credentialId: 'cred-o', userId: 'user-1', workflowId: 'wf-1' }) + ) + }) + + it('authorizes before requesting a token', async () => { + mockGetCredentialActorContext.mockResolvedValue({ + ...oauthContext, + credential: { ...oauthContext.credential, workspaceId: 'workspace-b' }, + }) + + await expect( + resolveVertexCredential({ + credentialId: 'cred-o', + actingUserId: 'user-1', + workspaceId: 'workspace-a', + }) + ).rejects.toThrow() + + expect(mockFetchCredentialAccessToken).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/utils/vertex-credential.ts b/apps/sim/executor/utils/vertex-credential.ts index 519bb80f044..5f4a2b17d13 100644 --- a/apps/sim/executor/utils/vertex-credential.ts +++ b/apps/sim/executor/utils/vertex-credential.ts @@ -1,9 +1,7 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' -import { getServiceAccountToken, refreshTokenIfNeeded } from '@/lib/oauth/credential-service' +import { getServiceAccountToken } from '@/lib/oauth/credential-service' +import { fetchCredentialAccessToken } from '@/executor/utils/credential-token' const logger = createLogger('VertexCredential') @@ -12,6 +10,8 @@ export interface ResolveVertexCredentialParams { actingUserId: string | undefined /** Workspace of the executing workflow. The credential must belong to it. */ workspaceId: string | null | undefined + /** Pins the token request to this workflow's workspace. */ + workflowId?: string callerLabel?: string } @@ -26,6 +26,7 @@ export async function resolveVertexCredential({ credentialId, actingUserId, workspaceId, + workflowId, callerLabel = 'vertex', }: ResolveVertexCredentialParams): Promise { const requestId = `${callerLabel}-${Date.now()}` @@ -64,16 +65,19 @@ export async function resolveVertexCredential({ throw new Error(`Vertex AI credential is not a valid OAuth credential: ${credentialId}`) } - const accountRow = await db.query.account.findFirst({ - where: eq(account.id, cred.accountId), + /** + * Fetched from the app rather than refreshed here: this runs inside the Trigger.dev + * worker, whose environment carries no OAuth client config, so an in-process refresh + * throws once the stored access token expires. The service-account branch above needs + * no such config and stays in-process. + */ + const accessToken = await fetchCredentialAccessToken({ + requestId, + credentialId, + userId: actingUserId, + workflowId, }) - if (!accountRow) { - throw new Error(`Vertex AI credential not found: ${credentialId}`) - } - - const { accessToken } = await refreshTokenIfNeeded(requestId, accountRow, cred.accountId) - if (!accessToken) { throw new Error('Failed to get Vertex AI access token') } From 1ef665826b79a8e8ac93469d1b15230ae140204c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 11:48:19 -0700 Subject: [PATCH 4/6] fix(selectors): drop the previous-options fallback so a context change cannot leave stale ones selectable --- apps/sim/hooks/selectors/use-selector-query.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/sim/hooks/selectors/use-selector-query.ts b/apps/sim/hooks/selectors/use-selector-query.ts index 57e47666f4f..213afcdab91 100644 --- a/apps/sim/hooks/selectors/use-selector-query.ts +++ b/apps/sim/hooks/selectors/use-selector-query.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react' import { createLogger } from '@sim/logger' -import { keepPreviousData, useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query' +import { useInfiniteQuery, useQueries, useQuery } from '@tanstack/react-query' import { extractEnvVarName, isEnvVarReference, isReference } from '@/executor/constants' import { usePersonalEnvironment } from '@/hooks/queries/environment' import { getSelectorDefinition, mergeOption } from '@/hooks/selectors/registry' @@ -84,8 +84,6 @@ export function useSelectorOptions( definition.fetchList?.({ ...queryArgs, signal }) ?? Promise.resolve([]), enabled: !supportsPagination && isEnabled, staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME, - /** `search` is part of the key, so without this the open dropdown empties on every edit. */ - placeholderData: keepPreviousData, }) const pagedQuery = useInfiniteQuery({ @@ -102,8 +100,6 @@ export function useSelectorOptions( initialPageParam: undefined as string | undefined, enabled: supportsPagination && isEnabled, staleTime: definition.staleTime ?? DEFAULT_SELECTOR_STALE_TIME, - /** Same reason as the flat query: the key carries `search`. */ - placeholderData: keepPreviousData, }) const { hasNextPage, isFetchingNextPage, fetchNextPage, isError } = pagedQuery From 3c18e2984b6d8b8070b9333e5dc5a7377be533d3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 12:11:48 -0700 Subject: [PATCH 5/6] fix(selectors): clear the search without waiting out the debounce --- .../components/selector-combobox/selector-combobox.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx index fb7381a057b..faf46bb6b1a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/selector-combobox/selector-combobox.tsx @@ -69,8 +69,14 @@ export function SelectorCombobox({ * The search reaches the provider, so it is debounced before it enters the query key * rather than on every keystroke — several of these selectors are rate-limited by the * provider. Only the query sees the debounced value; the input stays on `searchTerm`. + * + * Clearing is not debounced: a multi-select pick resets the term so the next choice comes + * from the full list, and waiting out the delay would leave the previous filtered results + * on screen. This mirrors the shared debounced-search setter, which also flushes empty. */ - const debouncedSearch = useDebounce(searchTerm.trim(), SEARCH_DEBOUNCE_MS) + const trimmedSearch = searchTerm.trim() + const debouncedSearch = useDebounce(trimmedSearch, SEARCH_DEBOUNCE_MS) + const activeSearch = trimmedSearch === '' ? '' : debouncedSearch const { data: options = [], isLoading, @@ -78,7 +84,7 @@ export function SelectorCombobox({ error, } = useSelectorOptions(selectorKey, { context: selectorContext, - search: allowSearch ? debouncedSearch : undefined, + search: allowSearch ? activeSearch : undefined, }) const { data: detailOption } = useSelectorOptionDetail(selectorKey, { context: selectorContext, From 80ec0eed299a851a66da1561b9cc67be35e35ed4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 13 Aug 2026 12:26:51 -0700 Subject: [PATCH 6/6] fix(realtime): wait for the streak-reset preconditions instead of sleeping past them --- .../src/handlers/file-doc-store.test.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/realtime/src/handlers/file-doc-store.test.ts b/apps/realtime/src/handlers/file-doc-store.test.ts index 815b755981f..802b18757b8 100644 --- a/apps/realtime/src/handlers/file-doc-store.test.ts +++ b/apps/realtime/src/handlers/file-doc-store.test.ts @@ -201,13 +201,26 @@ describe('FileDocStore', () => { const doc = new Y.Doc() await store.attachRoom(NAME, doc) - // Build a streak of two failures (retries back off ~0.5s, then ~1s). + // Build a streak of two failures. Waited for rather than slept through: on a loaded machine a + // fixed window can pass with fewer failures than the streak this test needs. state.backing!.readerClosed = true - await sleep(800) - // Redis comes back. Wait past the pending backoff so a read actually lands — and it returns - // nothing new, which is the idle case this test is about. + const beforeStreak = state.backing!.reads + await vi.waitFor(() => expect(state.backing!.reads).toBeGreaterThanOrEqual(beforeStreak + 2), { + timeout: 5000, + interval: 25, + }) + + // Redis comes back. Wait for a read to actually LAND — `xRead` only throws while the reader is + // closed, so the next one to arrive is the idle read whose return ends the streak. Sleeping a + // fixed 1s instead lets a slow machine finish the window with the pending backoff still + // outstanding, leaving the streak alive and the assertion below measuring a delay this test + // never meant to produce. state.backing!.readerClosed = false - await sleep(1000) + const beforeIdle = state.backing!.reads + await vi.waitFor(() => expect(state.backing!.reads).toBeGreaterThan(beforeIdle), { + timeout: 5000, + interval: 25, + }) // A fresh blip must retry at the START of the backoff curve, not partway up it. Assert the DELAY // itself: counting attempts inside a fixed window cannot tell the two apart, because the jittered