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 pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
|
||||||
let pluginCatalogRequestSeq = 0;
|
let pluginCatalogRequestSeq = 0;
|
||||||
|
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
||||||
let skillsCatalogRequestSeq = 0;
|
let skillsCatalogRequestSeq = 0;
|
||||||
let skillsDetailRequestSeq = 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 {
|
function getSkillsCatalogKey(projectPath?: string): string {
|
||||||
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +306,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
prev.pluginInstallProgress,
|
prev.pluginInstallProgress,
|
||||||
prev.installErrors
|
prev.installErrors
|
||||||
);
|
);
|
||||||
|
clearPluginSuccessResetTimers(pluginIdsToClear);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pluginCatalog: result,
|
pluginCatalog: result,
|
||||||
|
|
@ -292,6 +330,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
prev.pluginInstallProgress,
|
prev.pluginInstallProgress,
|
||||||
prev.installErrors
|
prev.installErrors
|
||||||
);
|
);
|
||||||
|
clearPluginSuccessResetTimers(
|
||||||
|
isSameProjectContext ? new Set<string>() : buildPluginIdSet(prev.pluginCatalog)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [],
|
pluginCatalog: isSameProjectContext ? prev.pluginCatalog : [],
|
||||||
|
|
@ -679,6 +720,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (preflightError) {
|
if (preflightError) {
|
||||||
|
clearPluginSuccessResetTimer(request.pluginId);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
installErrors: { ...prev.installErrors, [request.pluginId]: preflightError },
|
||||||
|
|
@ -686,6 +728,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPluginSuccessResetTimer(request.pluginId);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' },
|
||||||
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
installErrors: { ...prev.installErrors, [request.pluginId]: '' },
|
||||||
|
|
@ -711,13 +754,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
// Refresh catalog to pick up new installed state
|
// Refresh catalog to pick up new installed state
|
||||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||||
|
|
||||||
// Return to idle after brief success display
|
schedulePluginSuccessReset(request.pluginId, set);
|
||||||
setTimeout(() => {
|
|
||||||
set((prev) => ({
|
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'idle' },
|
|
||||||
}));
|
|
||||||
}, SUCCESS_DISPLAY_MS);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearPluginSuccessResetTimer(request.pluginId);
|
||||||
const message = err instanceof Error ? err.message : 'Install failed';
|
const message = err instanceof Error ? err.message : 'Install failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' },
|
||||||
|
|
@ -735,6 +774,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
? (projectPath ?? get().pluginCatalogProjectPath ?? undefined)
|
||||||
: projectPath;
|
: projectPath;
|
||||||
if (scope === 'project' && !effectiveProjectPath) {
|
if (scope === 'project' && !effectiveProjectPath) {
|
||||||
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE },
|
||||||
|
|
@ -742,6 +782,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' },
|
||||||
}));
|
}));
|
||||||
|
|
@ -763,12 +804,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
// Refresh catalog
|
// Refresh catalog
|
||||||
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true);
|
||||||
|
|
||||||
setTimeout(() => {
|
schedulePluginSuccessReset(pluginId, set);
|
||||||
set((prev) => ({
|
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' },
|
|
||||||
}));
|
|
||||||
}, SUCCESS_DISPLAY_MS);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearPluginSuccessResetTimer(pluginId);
|
||||||
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' },
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ describe('extensionsSlice', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -490,6 +491,25 @@ describe('extensionsSlice', () => {
|
||||||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
||||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
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', () => {
|
describe('uninstallPlugin', () => {
|
||||||
|
|
@ -524,6 +544,28 @@ describe('extensionsSlice', () => {
|
||||||
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
expect(store.getState().pluginInstallProgress['project@m']).toBe('error');
|
||||||
expect(store.getState().installErrors['project@m']).toContain('active project');
|
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', () => {
|
describe('installMcpServer', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue