fix(web): await replacement refresh coverage

This commit is contained in:
2026-07-22 14:21:33 +08:00
parent 51c6fea343
commit 19f9e163e2
6 changed files with 186 additions and 26 deletions

View File

@@ -55,3 +55,35 @@ test('full and targeted refreshes supersede only when their coverage overlaps gl
assert.equal(full.signal.aborted, true) assert.equal(full.signal.aborted, true)
assert.equal(newerProjectA.isCurrent(), true) assert.equal(newerProjectA.isCurrent(), true)
}) })
test('project A superseded by a failing full refresh is not considered covered', async () => {
const coordinator = createProjectRefreshCoordinator()
const projectA = coordinator.beginProject('project-a')
const full = coordinator.beginFull()
full.finish(false)
assert.equal(await projectA.waitForCoverage(), false)
})
test('only a successful replacement confirms coverage of a superseded project', async () => {
const coordinator = createProjectRefreshCoordinator()
const firstProjectA = coordinator.beginProject('project-a')
const newerProjectA = coordinator.beginProject('project-a')
newerProjectA.finish(true)
assert.equal(await firstProjectA.waitForCoverage(), true)
})
test('a successful full refresh covers every targeted request it superseded', async () => {
const coordinator = createProjectRefreshCoordinator()
const projectA = coordinator.beginProject('project-a')
const projectB = coordinator.beginProject('project-b')
const full = coordinator.beginFull()
full.finish(true)
assert.equal(await projectA.waitForCoverage(), true)
assert.equal(await projectB.waitForCoverage(), true)
})

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { runRecoveryRefresh } from '../src/app/recovery-refresh.ts'
test('a failed recovery never announces refreshed success', async () => {
const events = []
await assert.rejects(() => runRecoveryRefresh(
async () => {
events.push('refresh')
throw new Error('still stale')
},
() => events.push('success'),
), /still stale/)
assert.deepEqual(events, ['refresh'])
})
test('recovery announces success only after every requested refresh resolves', async () => {
const events = []
await runRecoveryRefresh(
async () => events.push('refresh'),
() => events.push('success'),
)
assert.deepEqual(events, ['refresh', 'success'])
})

View File

@@ -4,6 +4,7 @@ const requiredFiles = [
'src/app/App.tsx', 'src/app/App.tsx',
'src/app/mutation-refresh.ts', 'src/app/mutation-refresh.ts',
'src/app/project-refresh-coordinator.ts', 'src/app/project-refresh-coordinator.ts',
'src/app/recovery-refresh.ts',
'src/pages/login.tsx', 'src/pages/login.tsx',
'src/pages/workspace-body.tsx', 'src/pages/workspace-body.tsx',
'src/pages/workspace-home.tsx', 'src/pages/workspace-home.tsx',
@@ -33,6 +34,7 @@ const requiredFiles = [
'scripts/mutation-refresh.test.mjs', 'scripts/mutation-refresh.test.mjs',
'scripts/mutation-wiring.test.mjs', 'scripts/mutation-wiring.test.mjs',
'scripts/project-refresh-coordinator.test.mjs', 'scripts/project-refresh-coordinator.test.mjs',
'scripts/recovery-refresh.test.mjs',
] ]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`) const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)

View File

@@ -34,6 +34,7 @@ import {
mergePendingRefreshProjects, mergePendingRefreshProjects,
type ProjectRefreshCoordinator, type ProjectRefreshCoordinator,
} from './project-refresh-coordinator' } from './project-refresh-coordinator'
import { runRecoveryRefresh } from './recovery-refresh'
function App() { function App() {
const [screen, setScreen] = useState<Screen>('login') const [screen, setScreen] = useState<Screen>('login')
@@ -81,20 +82,33 @@ function App() {
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) { async function loadWorkspaces(nextSession: ApiSession, preferredProjectID?: string) {
const request = refreshCoordinator.current!.beginFull() const request = refreshCoordinator.current!.beginFull()
const projectIDWhenStarted = activeProjectIDRef.current const projectIDWhenStarted = activeProjectIDRef.current
const backendProjects = await fetchProjects(nextSession, request.signal) try {
const backendWorkspaces = await Promise.all( const backendProjects = await fetchProjects(nextSession, request.signal)
backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id, request.signal).then((workspace) => mapWorkspace(workspace, index))), const backendWorkspaces = await Promise.all(
) backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id, request.signal).then((workspace) => mapWorkspace(workspace, index))),
if (!request.isCurrent()) throw staleWorkspaceRefreshError() )
setWorkspaces(backendWorkspaces) if (!request.isCurrent()) {
const latestProjectID = activeProjectIDRef.current if (await request.waitForCoverage()) return workspaces
const requestedProjectID = latestProjectID !== projectIDWhenStarted throw staleWorkspaceRefreshError()
? latestProjectID }
: preferredProjectID ?? latestProjectID setWorkspaces(backendWorkspaces)
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? '' const latestProjectID = activeProjectIDRef.current
selectActiveProject(nextProjectID) const requestedProjectID = latestProjectID !== projectIDWhenStarted
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current)) ? latestProjectID
return backendWorkspaces : preferredProjectID ?? latestProjectID
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === requestedProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
selectActiveProject(nextProjectID)
request.finish(true)
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current))
return backendWorkspaces
} catch (error) {
if (request.isCurrent()) {
request.finish(false)
} else if (await request.waitForCoverage()) {
return workspaces
}
throw error
}
} }
async function refreshProjectWorkspaces(nextSession: ApiSession, projectIDs: string[]) { async function refreshProjectWorkspaces(nextSession: ApiSession, projectIDs: string[]) {
@@ -105,13 +119,25 @@ function App() {
const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID) const workspaceIndex = workspaces.findIndex((workspace) => workspace.project.id === projectID)
try { try {
const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal) const payload = await fetchProjectWorkspace(nextSession, projectID, request.signal)
if (!request.isCurrent()) return { projectID, status: 'superseded' as const } if (!request.isCurrent()) {
const covered = await request.waitForCoverage()
return covered
? { projectID, status: 'covered' as const }
: { projectID, status: 'failed' as const, error: staleWorkspaceRefreshError() }
}
const refreshed = mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex) const refreshed = mapWorkspace(payload, workspaceIndex < 0 ? 0 : workspaceIndex)
setWorkspaces((current) => mergeRefreshedWorkspaces(current, [refreshed])) setWorkspaces((current) => mergeRefreshedWorkspaces(current, [refreshed]))
request.finish(true)
setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current, [projectID])) setPendingRefreshProjectIDs((current) => clearPendingRefreshProjects(current, [projectID]))
return { projectID, status: 'refreshed' as const, workspace: refreshed } return { projectID, status: 'refreshed' as const, workspace: refreshed }
} catch (error) { } catch (error) {
if (!request.isCurrent()) return { projectID, status: 'superseded' as const } if (!request.isCurrent()) {
const covered = await request.waitForCoverage()
return covered
? { projectID, status: 'covered' as const }
: { projectID, status: 'failed' as const, error }
}
request.finish(false)
return { projectID, status: 'failed' as const, error } return { projectID, status: 'failed' as const, error }
} }
})) }))
@@ -153,8 +179,10 @@ function App() {
const targets = [...pendingRefreshProjectIDs] const targets = [...pendingRefreshProjectIDs]
setRefreshRetrying(true) setRefreshRetrying(true)
try { try {
await refreshProjectWorkspaces(requireSession(), targets) await runRecoveryRefresh(
Message.success('数据已刷新') () => refreshProjectWorkspaces(requireSession(), targets).then(() => undefined),
() => Message.success('数据已刷新'),
)
} catch (error) { } catch (error) {
Message.error(error instanceof Error ? error.message : '部分项目刷新失败,请稍后重试') Message.error(error instanceof Error ? error.message : '部分项目刷新失败,请稍后重试')
} finally { } finally {

View File

@@ -1,8 +1,21 @@
import { createWorkspaceRefreshGate } from './workspace-refresh-gate.ts' import { createWorkspaceRefreshGate, type WorkspaceRefreshTicket } from './workspace-refresh-gate.ts'
export type CoordinatedRefreshTicket = WorkspaceRefreshTicket & {
finish: (succeeded: boolean) => void
waitForCoverage: () => Promise<boolean>
}
type RefreshState = {
ticket: CoordinatedRefreshTicket
resolve: (succeeded: boolean) => void
outcome: Promise<boolean>
}
export function createProjectRefreshCoordinator() { export function createProjectRefreshCoordinator() {
const fullGate = createWorkspaceRefreshGate() const fullGate = createWorkspaceRefreshGate()
const projectGates = new Map<string, ReturnType<typeof createWorkspaceRefreshGate>>() const projectGates = new Map<string, ReturnType<typeof createWorkspaceRefreshGate>>()
const projectStates = new Map<string, RefreshState>()
let fullState: RefreshState | null = null
function projectGate(projectID: string) { function projectGate(projectID: string) {
const existing = projectGates.get(projectID) const existing = projectGates.get(projectID)
@@ -12,18 +25,70 @@ export function createProjectRefreshCoordinator() {
return created return created
} }
function stateFor(base: WorkspaceRefreshTicket): RefreshState {
let settle!: (succeeded: boolean) => void
let settled = false
const outcome = new Promise<boolean>((resolve) => {
settle = (succeeded) => {
if (settled) return
settled = true
resolve(succeeded)
}
})
return {
resolve: settle,
outcome,
ticket: {
...base,
finish: settle,
waitForCoverage: () => outcome,
},
}
}
function coveredBy(replaced: RefreshState | null | undefined, replacement: RefreshState) {
if (!replaced) return
void replacement.outcome.then(replaced.resolve)
}
return { return {
beginFull() { beginFull(): CoordinatedRefreshTicket {
for (const gate of projectGates.values()) gate.invalidate() const previousFull = fullState
return fullGate.begin() const next = stateFor(fullGate.begin())
coveredBy(previousFull, next)
fullState = next
for (const [projectID, state] of projectStates) {
projectGates.get(projectID)?.invalidate()
coveredBy(state, next)
}
projectStates.clear()
return next.ticket
}, },
beginProject(projectID: string) { beginProject(projectID: string): CoordinatedRefreshTicket {
fullGate.invalidate() if (fullState) {
return projectGate(projectID).begin() fullGate.invalidate()
fullState.resolve(false)
fullState = null
} else {
fullGate.invalidate()
}
const previous = projectStates.get(projectID)
const next = stateFor(projectGate(projectID).begin())
coveredBy(previous, next)
projectStates.set(projectID, next)
return next.ticket
}, },
invalidateAll() { invalidateAll() {
fullGate.invalidate() fullGate.invalidate()
for (const gate of projectGates.values()) gate.invalidate() fullState?.resolve(false)
fullState = null
for (const [projectID, state] of projectStates) {
projectGates.get(projectID)?.invalidate()
state.resolve(false)
}
projectStates.clear()
projectGates.clear() projectGates.clear()
}, },
} }

View File

@@ -0,0 +1,4 @@
export async function runRecoveryRefresh(refresh: () => Promise<void>, onSuccess: () => void) {
await refresh()
onSuccess()
}