Skip to content

Commit 934b0f2

Browse files
committed
fix(gateway): drop resolution cache on disconnect; unstick TS tests
FeatureService.mark_unavailable wrote the repo flip but left resolve_feature_sets serving the pre-disconnect tool list. Invalidate the space cache there (and on delete_for_server) so clients stop seeing tools from a dead server. TS suite was failing on stale workspaceBindings mocks missing isIdBinding, persist writes against a missing localStorage, and a wizard payload that now includes label. importOriginal those mocks, install a memory store in setup, and match the create args. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 1e4cecf commit 934b0f2

10 files changed

Lines changed: 119 additions & 48 deletions

File tree

crates/mcpmux-gateway/src/pool/features/facade.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,17 @@ impl FeatureService {
6969
}
7070

7171
pub async fn mark_unavailable(&self, space_id: &str, server_id: &str) -> Result<()> {
72-
self.discovery.mark_unavailable(space_id, server_id).await
72+
self.discovery.mark_unavailable(space_id, server_id).await?;
73+
self.resolution.invalidate_space(space_id).await;
74+
Ok(())
7375
}
7476

7577
pub async fn delete_for_server(&self, space_id: &str, server_id: &str) -> Result<()> {
76-
self.discovery.delete_for_server(space_id, server_id).await
78+
self.discovery
79+
.delete_for_server(space_id, server_id)
80+
.await?;
81+
self.resolution.invalidate_space(space_id).await;
82+
Ok(())
7783
}
7884

7985
// Delegate to FeatureResolutionService

crates/mcpmux-gateway/src/pool/features/resolution.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@ impl FeatureResolutionService {
101101
});
102102
}
103103

104-
async fn invalidate_space(&self, space_id: &str) {
104+
/// Drop cached resolutions for one space. Called from the event listener
105+
/// and from `FeatureService::mark_unavailable` so a disconnect does not
106+
/// keep serving a pre-disconnect tool list.
107+
pub async fn invalidate_space(&self, space_id: &str) {
105108
self.cache
106109
.write()
107110
.await

tests/rust/tests/integration/mcp_flows.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,8 +694,10 @@ async fn test_server_disconnect_marks_features_unavailable() {
694694
.unwrap();
695695
assert_eq!(tools_before.len(), 2);
696696

697-
// Simulate server disconnect
698-
ctx.feature_repo
697+
// Simulate server disconnect through FeatureService so the resolution
698+
// cache drops with the availability flip (repo-only writes leave the
699+
// cache serving the pre-disconnect tool list).
700+
ctx.service
699701
.mark_unavailable(&ctx.space_id, "server")
700702
.await
701703
.unwrap();

tests/ts/components/App.test.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,13 @@ vi.mock('@/lib/api/featureSets', () => ({
137137
vi.mock('@/lib/api/registry', () => ({
138138
listInstalledServers: vi.fn().mockResolvedValue([]),
139139
}));
140-
vi.mock('@/lib/api/workspaceBindings', () => ({
141-
listWorkspaceBindings: vi.fn().mockResolvedValue([]),
142-
}));
140+
vi.mock('@/lib/api/workspaceBindings', async (importOriginal) => {
141+
const actual = await importOriginal<typeof import('@/lib/api/workspaceBindings')>();
142+
return {
143+
...actual,
144+
listWorkspaceBindings: vi.fn().mockResolvedValue([]),
145+
};
146+
});
143147
vi.mock('@/lib/api/serverManager', () => ({
144148
getServerStatuses: vi.fn().mockResolvedValue({}),
145149
}));

tests/ts/components/DashboardPageStats.test.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ vi.mock('@/lib/api/featureSets', () => ({
3333
}));
3434
vi.mock('@/lib/api/gateway', () => ({ getGatewayStatus: mockGatewayStatus }));
3535
vi.mock('@/lib/api/registry', () => ({ listInstalledServers: mockListInstalled }));
36-
vi.mock('@/lib/api/workspaceBindings', () => ({ listWorkspaceBindings: mockListBindings }));
36+
vi.mock('@/lib/api/workspaceBindings', async (importOriginal) => {
37+
const actual = await importOriginal<typeof import('@/lib/api/workspaceBindings')>();
38+
return { ...actual, listWorkspaceBindings: mockListBindings };
39+
});
3740
vi.mock('@/lib/api/serverManager', () => ({ getServerStatuses: mockServerStatuses }));
3841
vi.mock('@/stores', () => ({
3942
useViewSpace: () => ({ id: 'space-1', name: 'My Space' }),

tests/ts/components/WorkspaceBindingPrompt.test.tsx

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,28 @@ const { workspaceHandlers } = vi.hoisted(() => ({
1212
workspaceHandlers: new Map<string, (payload: unknown) => void>(),
1313
}));
1414

15-
vi.mock('@/lib/api/workspaceBindings', () => ({
16-
createWorkspaceBinding: vi.fn(),
17-
updateWorkspaceBinding: vi.fn().mockResolvedValue(undefined),
18-
listWorkspaceBindings: vi.fn().mockResolvedValue([]),
19-
validateWorkspaceRoot: vi.fn().mockResolvedValue('/home/u/proj'),
20-
getWorkspaceEffectiveFeatures: vi.fn().mockResolvedValue({
21-
workspace_root: '/home/u/proj',
22-
source: 'unbound',
23-
binding_id: null,
24-
space_id: 's1',
25-
space_name: 'Default',
26-
feature_sets: [{ id: 'fs1', name: 'Starter', feature_set_type: 'starter' }],
27-
tools: [],
28-
prompts: [],
29-
resources: [],
30-
server_totals: {},
31-
}),
32-
}));
15+
vi.mock('@/lib/api/workspaceBindings', async (importOriginal) => {
16+
const actual = await importOriginal<typeof import('@/lib/api/workspaceBindings')>();
17+
return {
18+
...actual,
19+
createWorkspaceBinding: vi.fn(),
20+
updateWorkspaceBinding: vi.fn().mockResolvedValue(undefined),
21+
listWorkspaceBindings: vi.fn().mockResolvedValue([]),
22+
validateWorkspaceRoot: vi.fn().mockResolvedValue('/home/u/proj'),
23+
getWorkspaceEffectiveFeatures: vi.fn().mockResolvedValue({
24+
workspace_root: '/home/u/proj',
25+
source: 'unbound',
26+
binding_id: null,
27+
space_id: 's1',
28+
space_name: 'Default',
29+
feature_sets: [{ id: 'fs1', name: 'Starter', feature_set_type: 'starter' }],
30+
tools: [],
31+
prompts: [],
32+
resources: [],
33+
server_totals: {},
34+
}),
35+
};
36+
});
3337

3438
vi.mock('@/lib/api/spaces', () => ({
3539
listSpaces: vi.fn().mockResolvedValue([{ id: 's1', name: 'Default', is_default: true }]),

tests/ts/components/WorkspaceSetupWizard.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ const { validateMock } = vi.hoisted(() => ({
1818
// `@tauri-apps/plugin-dialog` is mocked globally in setup.ts (open: vi.fn()).
1919
// We reconfigure that shared mock per-test via vi.importMock (a static import
2020
// of this mocked-only package isn't Vite-resolvable from the test).
21-
vi.mock('@/lib/api/workspaceBindings', () => ({ validateWorkspaceRoot: validateMock }));
21+
vi.mock('@/lib/api/workspaceBindings', async (importOriginal) => {
22+
const actual = await importOriginal<typeof import('@/lib/api/workspaceBindings')>();
23+
return { ...actual, validateWorkspaceRoot: validateMock };
24+
});
2225
vi.mock('@/lib/api/featureSets', () => ({
2326
isStarterFeatureSet: (fs: { feature_set_type: string }) =>
2427
fs.feature_set_type === 'starter' || fs.feature_set_type === 'default',
@@ -82,6 +85,7 @@ describe('WorkspaceSetupWizard', () => {
8285
space_id: 's1',
8386
feature_set_ids: ['fs_starter'],
8487
binding_type: 'path',
88+
label: null,
8589
});
8690
// The parent navigates to the new mapping's inspector (effective features);
8791
// the wizard itself does not close.

tests/ts/components/WorkspacesClearUnmapped.test.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,20 @@ const {
2525
clearUnmappedReportedRootsMock: vi.fn(),
2626
}));
2727

28-
vi.mock('@/lib/api/workspaceBindings', () => ({
29-
listWorkspaceBindings: listWorkspaceBindingsMock,
30-
listReportedWorkspaceRoots: listReportedWorkspaceRootsMock,
31-
clearUnmappedReportedRoots: clearUnmappedReportedRootsMock,
32-
createWorkspaceBinding: vi.fn(),
33-
updateWorkspaceBinding: vi.fn(),
34-
deleteWorkspaceBinding: vi.fn(),
35-
getWorkspaceEffectiveFeatures: vi.fn(),
36-
validateWorkspaceRoot: vi.fn(),
37-
}));
28+
vi.mock('@/lib/api/workspaceBindings', async (importOriginal) => {
29+
const actual = await importOriginal<typeof import('@/lib/api/workspaceBindings')>();
30+
return {
31+
...actual,
32+
listWorkspaceBindings: listWorkspaceBindingsMock,
33+
listReportedWorkspaceRoots: listReportedWorkspaceRootsMock,
34+
clearUnmappedReportedRoots: clearUnmappedReportedRootsMock,
35+
createWorkspaceBinding: vi.fn(),
36+
updateWorkspaceBinding: vi.fn(),
37+
deleteWorkspaceBinding: vi.fn(),
38+
getWorkspaceEffectiveFeatures: vi.fn(),
39+
validateWorkspaceRoot: vi.fn(),
40+
};
41+
});
3842

3943
vi.mock('@/lib/api/featureSets', () => ({
4044
listFeatureSets: vi.fn().mockResolvedValue([]),

tests/ts/components/WorkspacesMappedFilter.test.tsx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,20 @@ const { listWorkspaceBindingsMock, listReportedWorkspaceRootsMock } = vi.hoisted
1919
listReportedWorkspaceRootsMock: vi.fn(),
2020
}));
2121

22-
vi.mock('@/lib/api/workspaceBindings', () => ({
23-
listWorkspaceBindings: listWorkspaceBindingsMock,
24-
listReportedWorkspaceRoots: listReportedWorkspaceRootsMock,
25-
clearUnmappedReportedRoots: vi.fn(),
26-
createWorkspaceBinding: vi.fn(),
27-
updateWorkspaceBinding: vi.fn(),
28-
deleteWorkspaceBinding: vi.fn(),
29-
getWorkspaceEffectiveFeatures: vi.fn(),
30-
validateWorkspaceRoot: vi.fn(),
31-
}));
22+
vi.mock('@/lib/api/workspaceBindings', async (importOriginal) => {
23+
const actual = await importOriginal<typeof import('@/lib/api/workspaceBindings')>();
24+
return {
25+
...actual,
26+
listWorkspaceBindings: listWorkspaceBindingsMock,
27+
listReportedWorkspaceRoots: listReportedWorkspaceRootsMock,
28+
clearUnmappedReportedRoots: vi.fn(),
29+
createWorkspaceBinding: vi.fn(),
30+
updateWorkspaceBinding: vi.fn(),
31+
deleteWorkspaceBinding: vi.fn(),
32+
getWorkspaceEffectiveFeatures: vi.fn(),
33+
validateWorkspaceRoot: vi.fn(),
34+
};
35+
});
3236

3337
vi.mock('@/lib/api/featureSets', () => ({
3438
listFeatureSets: vi

tests/ts/setup.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
11
import '@testing-library/jest-dom';
22
import { vi } from 'vitest';
33

4+
/**
5+
* Zustand persist writes `viewSpaceId` / theme via `localStorage.setItem`.
6+
* jsdom usually provides it, but some workers expose `undefined` and every
7+
* store mutation then throws. Install a memory backend when the real one is
8+
* missing so appStore tests and persist-backed UI clicks stay runnable.
9+
*/
10+
function ensureLocalStorage(): void {
11+
const existing = (globalThis as { localStorage?: Storage }).localStorage;
12+
if (existing && typeof existing.setItem === 'function') {
13+
return;
14+
}
15+
const store = new Map<string, string>();
16+
const memory: Storage = {
17+
get length() {
18+
return store.size;
19+
},
20+
clear: () => {
21+
store.clear();
22+
},
23+
getItem: (key: string) => store.get(key) ?? null,
24+
key: (index: number) => [...store.keys()][index] ?? null,
25+
removeItem: (key: string) => {
26+
store.delete(key);
27+
},
28+
setItem: (key: string, value: string) => {
29+
store.set(key, String(value));
30+
},
31+
};
32+
Object.defineProperty(globalThis, 'localStorage', {
33+
configurable: true,
34+
writable: true,
35+
value: memory,
36+
});
37+
}
38+
39+
ensureLocalStorage();
40+
441
// Mock Tauri core API
542
vi.mock('@tauri-apps/api/core', () => ({
643
invoke: vi.fn(),

0 commit comments

Comments
 (0)