mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Harden unified resource refresh races
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user