Harden unified resource refresh races

This commit is contained in:
rcourtman
2026-05-14 15:26:08 +01:00
parent 92a5cb68ec
commit 4622df523d
4 changed files with 94 additions and 10 deletions
@@ -375,6 +375,10 @@ bypass the API fail-closed execution gate.
connected-system continuity only with delayed canonical REST revalidation;
storage and recovery routes must continue to require canonical REST first
unless their own contract is updated with equivalent shape-stability proof.
The shared `useUnifiedResources()` scope lifecycle is also a stability
boundary for storage/recovery consumers: org-scope or enabled-state changes
must invalidate stale in-flight REST refreshes before their errors or
request-guard cleanup can leak into the active resource snapshot.
Shared chart transports in `internal/api/router.go` must follow the same
rule in mock mode: `/api/storage-charts` and adjacent infrastructure chart
payloads must read through `GetUnifiedReadStateOrSnapshot()` so storage and
@@ -327,6 +327,11 @@ AI-only summary payloads, or page-local heuristics.
REST revalidation after the first-paint settle window so the page can paint
from live state immediately without forcing a second resource-shape
transition while summary and table surfaces are still mounting.
Org-scope and enabled-state transitions in
`frontend-modern/src/hooks/useUnifiedResources.ts` must invalidate older
in-flight REST refreshes before publishing the new scoped cache entry, so a
stale request cannot set active-scope errors, clear the active request guard,
or replace the currently mounted Infrastructure/Workloads resource snapshot.
Canonical cluster membership in that shared path must come only from
explicit cluster identity such as Kubernetes context or platform cluster
labels; standalone resource names must never be repurposed as synthetic
@@ -119,6 +119,22 @@ const waitForValue = async <T>(readValue: () => T, expected: T) => {
throw new Error(`Timed out waiting for expected value: ${String(expected)}`);
};
const deferred = <T>() => {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const resourceResponse = (data: unknown[]) => ({
ok: true,
status: 200,
json: async () => ({ data }),
});
describe('useUnifiedResources', () => {
let apiFetchMock: ReturnType<typeof vi.fn>;
let setWsState: SetStoreFunction<TestWsState>;
@@ -1724,6 +1740,55 @@ describe('useUnifiedResources', () => {
dispose();
});
it('ignores stale refetch errors and keeps the active scope request guard', async () => {
const staleDefaultRequest = deferred<ReturnType<typeof resourceResponse>>();
const activeTenantRequest = deferred<ReturnType<typeof resourceResponse>>();
apiFetchMock
.mockImplementationOnce(() => staleDefaultRequest.promise)
.mockImplementationOnce(() => activeTenantRequest.promise);
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources();
});
await flushAsync();
expect(apiFetchMock).toHaveBeenCalledTimes(1);
expect(result!.loading()).toBe(true);
eventBus.emit('org_switched', 'tenant-b');
await flushAsync();
expect(apiFetchMock).toHaveBeenCalledTimes(2);
staleDefaultRequest.reject(new Error('stale default-scope refresh failed'));
await flushAsync();
expect(result!.error()).toBeUndefined();
expect(result!.loading()).toBe(true);
void result!.refetch().catch(() => undefined);
await flushAsync();
expect(apiFetchMock).toHaveBeenCalledTimes(2);
activeTenantRequest.resolve(
resourceResponse([
{
...v2Resource,
id: 'tenant-node',
name: 'tenant-node',
},
]),
);
await waitForValue(() => result!.resources()[0]?.id, 'tenant-node');
expect(result!.error()).toBeUndefined();
expect(result!.loading()).toBe(false);
dispose();
});
it('normalizes proxmox service aliases into canonical platform types', async () => {
apiFetchMock.mockResolvedValueOnce({
ok: true,
@@ -1121,13 +1121,16 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const requestVersion = scopeVersion;
const entryForRequest = cacheEntry;
const request = (async () => {
const isCurrentRequest = () =>
requestVersion === scopeVersion && entryForRequest === cacheEntry && enabled();
try {
const fetched = await fetchUnifiedResourcesShared(
entryForRequest,
query,
shouldForceNetwork,
);
if (requestVersion !== scopeVersion || entryForRequest !== cacheEntry) {
if (!isCurrentRequest()) {
return resources as unknown as Resource[];
}
batch(() => {
@@ -1136,13 +1139,15 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
});
return fetched;
} catch (err) {
if (!background) {
if (!background && isCurrentRequest()) {
setError(err);
}
throw err;
} finally {
inFlightRefetch = null;
if (shouldShowLoading) {
if (inFlightRefetch === request) {
inFlightRefetch = null;
}
if (shouldShowLoading && isCurrentRequest()) {
setLoading(false);
}
}
@@ -1247,6 +1252,8 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
orgScope();
if (!enabled()) {
scopeVersion += 1;
inFlightRefetch = null;
clearInitialHydrationTimeout();
clearCanonicalRevalidationTimeout();
clearRefreshTimeout();
@@ -1389,21 +1396,24 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
}
scopeVersion += 1;
blockedWsHydrationToken = supportsCanonicalWsHydration
? String(wsStore.state.lastUpdate ?? '')
: null;
setOrgScope(nextOrgScope);
cacheEntry = seedUnifiedResourcesCacheFromAllResources(
getUnifiedResourcesCacheEntry(resolveScopedCacheKey()),
const nextCacheEntry = seedUnifiedResourcesCacheFromAllResources(
getUnifiedResourcesCacheEntry(
buildScopedUnifiedResourcesCacheKey(cacheKey, nextOrgScope),
),
cacheKey,
query,
nextOrgScope,
);
blockedWsHydrationToken = supportsCanonicalWsHydration
? String(wsStore.state.lastUpdate ?? '')
: null;
cacheEntry = nextCacheEntry;
inFlightRefetch = null;
wsInitialized = false;
lastWsUpdateToken = '';
clearInitialHydrationTimeout();
clearCanonicalRevalidationTimeout();
setOrgScope(nextOrgScope);
const scopedResources = cacheEntry.resources;
const scopedPolicyPosture = cacheEntry.policyPosture;