fix: preserve newer hosted review lookup ownership (#23126)

* fix: preserve newer hosted review lookup ownership

* refactor: reuse the shared lookup generation sequence

Drops this coordinator's private counter for the identical allocator added on
the pull-request branch, so the "never reuse a generation id" invariant lives in
one place. The file is byte-identical on both branches, so either may merge
first.

* docs(store): describe the lookup generation sequence by its contract, not its callers

The JSDoc claimed the sequence was "shared by the pull-request and hosted-review
request coordinators", but the pull-request call site arrives in a sibling
change, so on this branch alone the comment named a caller that does not exist.

Describe what the module provides instead: one process-lifetime monotonic
allocator for lookup-ownership stamps, safe to share across every cache because
callers compare stamps for equality only, never order or magnitude. That stays
accurate whether one coordinator draws from it or several, so it needs no edit
when the second call site lands.

Applied identically on the sibling branch so the file stays byte-identical and
the two add/add introductions keep merging without conflict.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-09-26 23:37:45 -07:00
committed by GitHub
co-authored by Claude
parent 6ccfc1246c
commit 5da18e154f
2 changed files with 171 additions and 1 deletions
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import type { HostedReviewInfo } from '../../../../shared/hosted-review'
import type { AppState } from '../types'
import { createHostedReviewSlice } from './hosted-review'
import {
_clearHostedReviewRequestGenerationsForTest,
hostedReviewRequestGenerations,
inflightHostedReviewRequests
} from './hosted-review-request-state'
const runtimeRpc = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() }))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: runtimeRpc.callRuntimeRpc,
getActiveRuntimeTarget: (settings: AppState['settings']) => {
const environmentId = settings?.activeRuntimeEnvironmentId
return environmentId ? { kind: 'environment', environmentId } : { kind: 'local' }
}
}))
const forBranch = vi.fn()
type TestState = Pick<
AppState,
| 'settings'
| 'repos'
| 'prCache'
| 'hostedReviewCache'
| 'fetchHostedReviewForBranch'
| 'getHostedReviewCreationEligibility'
| 'createHostedReview'
| 'createStackedHostedReview'
>
function makeStore(runtime = false) {
return create<TestState>()((...args) => ({
settings: null,
repos: [
{
id: 'repo',
path: '/repo',
displayName: 'Repo',
badgeColor: 'blue',
addedAt: 1,
...(runtime ? { executionHostId: 'runtime:env-1' } : {})
}
],
prCache: {},
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This harness provides every state field read by the hosted-review slice.
...createHostedReviewSlice(...(args as Parameters<typeof createHostedReviewSlice>))
}))
}
function review(number: number): HostedReviewInfo {
return {
provider: 'gitlab',
number,
title: `Review ${number}`,
state: 'open',
url: `https://gitlab.com/group/project/-/merge_requests/${number}`,
status: 'success',
updatedAt: '2026-09-25T00:00:00Z',
mergeable: 'MERGEABLE'
}
}
beforeEach(() => {
_clearHostedReviewRequestGenerationsForTest()
vi.useFakeTimers()
vi.setSystemTime(1000)
forBranch.mockReset()
runtimeRpc.callRuntimeRpc.mockReset()
vi.stubGlobal('window', { api: { hostedReview: { forBranch } } })
vi.spyOn(console, 'error').mockImplementation(() => undefined)
})
afterEach(() => {
_clearHostedReviewRequestGenerationsForTest()
vi.useRealTimers()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
const cases = [
{ route: 'local', replacement: 'force', outcome: 'found' },
{ route: 'local', replacement: 'force', outcome: 'rejected' },
{ route: 'local', replacement: 'hint', outcome: 'found' },
{ route: 'runtime', replacement: 'force', outcome: 'found' },
{ route: 'runtime', replacement: 'force', outcome: 'missing' },
{ route: 'runtime', replacement: 'hint', outcome: 'rejected' }
] as const
describe('hosted review lookup ownership after newer settlement', () => {
it.each(cases)(
'preserves $route lookup after late $outcome with $replacement replacement',
async ({ route, replacement, outcome }) => {
const store = makeStore(route === 'runtime')
const pending: ReturnType<typeof Promise.withResolvers<HostedReviewInfo | null>>[] = []
const startRequest = () => {
const request = Promise.withResolvers<HostedReviewInfo | null>()
pending.push(request)
return request.promise
}
const dispatch = route === 'runtime' ? runtimeRpc.callRuntimeRpc : forBranch
dispatch.mockImplementation(startRequest)
const branch = `${route}-${replacement}-${outcome}`
const firstOptions = replacement === 'hint' ? { force: true, linkedGitLabMR: 10 } : {}
const first = store.getState().fetchHostedReviewForBranch('/repo', branch, firstOptions)
vi.setSystemTime(2000)
const options = replacement === 'hint' ? { force: true, linkedGitLabMR: 20 } : { force: true }
const second = store.getState().fetchHostedReviewForBranch('/repo', branch, options)
expect(pending).toHaveLength(2)
pending[1].resolve(review(20))
await second
expect(hostedReviewRequestGenerations.size).toBe(0)
vi.setSystemTime(3000)
const third = store.getState().fetchHostedReviewForBranch('/repo', branch, options)
expect(pending).toHaveLength(3)
const entry = [...inflightHostedReviewRequests.entries()].at(-1)
expect(entry).toBeDefined()
if (!entry) {
throw new Error('Expected a pending lookup')
}
const [key, owner] = entry
const cacheKey = [...hostedReviewRequestGenerations.keys()][0]
const generationBefore = hostedReviewRequestGenerations.get(cacheKey)
if (outcome === 'rejected') {
pending[0].reject(new Error('Old lookup failed'))
} else {
pending[0].resolve(outcome === 'missing' ? null : review(10))
}
await first
const currentOwner = inflightHostedReviewRequests.get(key)
const generationAfter = hostedReviewRequestGenerations.get(cacheKey)
const cachedBefore = store.getState().hostedReviewCache[cacheKey].data
const follower = store.getState().fetchHostedReviewForBranch('/repo', branch, options)
const requestCount = pending.length
pending[2].resolve(review(30))
pending[3]?.resolve(review(40))
const results = await Promise.all([third, follower])
expect(currentOwner).toBe(owner)
expect(generationAfter).toBe(generationBefore)
expect(cachedBefore?.number).toBe(20)
expect(requestCount).toBe(3)
expect(results.map((result) => result?.number)).toEqual([30, 30])
expect(store.getState().hostedReviewCache[cacheKey].data?.number).toBe(30)
expect(inflightHostedReviewRequests.size).toBe(0)
expect(hostedReviewRequestGenerations.size).toBe(0)
expect(route === 'runtime' ? forBranch : runtimeRpc.callRuntimeRpc).not.toHaveBeenCalled()
}
)
it('dedupes each branch independently and removes settled generation entries', async () => {
const store = makeStore()
const pending = Promise.withResolvers<HostedReviewInfo | null>()
forBranch.mockReturnValue(pending.promise)
const callers = ['one', 'two'].flatMap((branch) =>
Array.from({ length: 10 }, () =>
store.getState().fetchHostedReviewForBranch('/repo', branch, { force: true })
)
)
expect(forBranch).toHaveBeenCalledTimes(2)
expect(hostedReviewRequestGenerations.size).toBe(2)
pending.resolve(review(20))
await Promise.all(callers)
expect(inflightHostedReviewRequests.size).toBe(0)
expect(hostedReviewRequestGenerations.size).toBe(0)
})
})
@@ -8,6 +8,7 @@ import type {
} from '../../../../shared/hosted-review'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { AppState } from '../types'
import { nextLookupGeneration } from '../lookup-generation-sequence'
import {
getHostedReviewCacheKey,
linkedReviewHintKey,
@@ -186,7 +187,7 @@ export const createHostedReviewSlice: StateCreator<AppState, [], [], HostedRevie
const inflightRequest = inflightHostedReviewRequests.get(requestKey)
const startRequest = (): Promise<HostedReviewInfo | null> => {
const generation = (requestGenerations.get(cacheKey) ?? 0) + 1
const generation = nextLookupGeneration()
const requestStartedAt = Date.now()
const requestStartedEntry = get().hostedReviewCache[cacheKey]
requestGenerations.set(cacheKey, generation)