fix(extensions): cancel stale plugin success timers
This commit is contained in:
parent
560174d98c
commit
2b8062dfa3
2 changed files with 91 additions and 11 deletions
|
|
@ -129,6 +129,7 @@ export interface ExtensionsSlice {
|
|||
|
||||
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
|
||||
let pluginCatalogRequestSeq = 0;
|
||||
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
||||
let skillsCatalogRequestSeq = 0;
|
||||
let skillsDetailRequestSeq = 0;
|
||||
|
|
@ -175,6 +176,42 @@ function clearPluginOperationState(
|
|||
};
|
||||
}
|
||||
|
||||
function clearPluginSuccessResetTimer(pluginId: string): void {
|
||||
const timer = pluginSuccessResetTimers.get(pluginId);
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
pluginSuccessResetTimers.delete(pluginId);
|
||||
}
|
||||
|
||||
function clearPluginSuccessResetTimers(pluginIds: Set<string>): void {
|
||||
for (const pluginId of pluginIds) {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePluginSuccessReset(
|
||||
pluginId: string,
|
||||
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
|
||||
): void {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
const timer = setTimeout(() => {
|
||||
pluginSuccessResetTimers.delete(pluginId);
|
||||
set((prev) => {
|
||||
if (prev.pluginInstallProgress[pluginId] !== 'success') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
||||
};
|
||||
});
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
pluginSuccessResetTimers.set(pluginId, timer);
|
||||
}
|
||||
|
||||
function getSkillsCatalogKey(projectPath?: string): string {
|
||||
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||
}
|
||||
|
|
@ -269,6 +306,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
prev.pluginInstallProgress,
|
||||
prev.installErrors
|
||||
);
|
||||
clearPluginSuccessResetTimers(pluginIdsToClear);
|
||||
|
||||
return {
|
||||
pluginCatalog: result,
|
||||
|
|
@ -292,6 +330,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
prev.pluginInstallProgress,
|
||||
prev.installErrors
|
||||
);
|
||||
clearPluginSuccessResetTimers(
|
||||
isSameProjectContext ? new Set<string>() : buildPluginIdSet(prev.pluginCatalog)
|
||||
);
|
||||
|
||||
return {
|
||||
pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [],
|
||||
|
|
@ -679,6 +720,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
: null;
|
||||
|
||||
if (preflightError) {
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
||||
|
|
@ -686,6 +728,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
return;
|
||||
}
|
||||
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
||||
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
||||
|
|
@ -711,13 +754,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
// Refresh catalog to pick up new installed state
|
||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||
|
||||
// Return to idle after brief success display
|
||||
setTimeout(() => {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
|
||||
}));
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
schedulePluginSuccessReset(request.pluginId, set);
|
||||
} catch (err) {
|
||||
clearPluginSuccessResetTimer(request.pluginId);
|
||||
const message = err instanceof Error ? err.message : 'Install failed';
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||
|
|
@ -735,6 +774,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||
: projectPath;
|
||||
if (scope === 'project' && !effectiveProjectPath) {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
||||
|
|
@ -742,6 +782,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
return;
|
||||
}
|
||||
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
|
||||
}));
|
||||
|
|
@ -763,12 +804,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
// Refresh catalog
|
||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||
|
||||
setTimeout(() => {
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
||||
}));
|
||||
}, SUCCESS_DISPLAY_MS);
|
||||
schedulePluginSuccessReset(pluginId, set);
|
||||
} catch (err) {
|
||||
clearPluginSuccessResetTimer(pluginId);
|
||||
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||
set((prev) => ({
|
||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ describe('extensionsSlice', () => {
|
|||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
|
|
@ -490,6 +491,25 @@ describe('extensionsSlice', () => {
|
|||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
||||
});
|
||||
|
||||
it('clears older success reset timers before a new operation on the same plugin', async () => {
|
||||
vi.useFakeTimers();
|
||||
store.setState({ cliStatus: makeReadyCliStatus() });
|
||||
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(api.plugins!.install as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce({ state: 'success' })
|
||||
.mockResolvedValueOnce({ state: 'error', error: 'second failure' });
|
||||
|
||||
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||
|
||||
await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' });
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstallPlugin', () => {
|
||||
|
|
@ -524,6 +544,28 @@ describe('extensionsSlice', () => {
|
|||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
||||
});
|
||||
|
||||
it('does not restore idle state after project switch clears a pending success timer', async () => {
|
||||
vi.useFakeTimers();
|
||||
store.setState({
|
||||
pluginCatalogProjectPath: '/tmp/project-a',
|
||||
pluginCatalog: [makePlugin({ pluginId: 'test@m' })],
|
||||
});
|
||||
(api.plugins!.getAll as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce([makePlugin({ pluginId: 'test@m' })])
|
||||
.mockResolvedValueOnce([makePlugin({ pluginId: 'other@m' })]);
|
||||
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||
|
||||
await store.getState().uninstallPlugin('test@m', 'user');
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBe('success');
|
||||
|
||||
await store.getState().fetchPluginCatalog('/tmp/project-b');
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
|
||||
expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('installMcpServer', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue