fix(extensions): cancel stale plugin success timers

This commit is contained in:
777genius 2026-04-16 22:07:22 +03:00
parent 560174d98c
commit 2b8062dfa3
2 changed files with 91 additions and 11 deletions

View file

@ -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' },

View file

@ -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', () => {