Merge pull request #2050 from modelcontextprotocol/v2/fix/2049-unhandled-list-reload-rejections
fix: handle background command rejections in App (#2049)
This commit is contained in:
commit
b020709d51
2 changed files with 229 additions and 65 deletions
|
|
@ -579,6 +579,8 @@ import {
|
||||||
import * as McpIndex from "@inspector/core/mcp/index.js";
|
import * as McpIndex from "@inspector/core/mcp/index.js";
|
||||||
import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
||||||
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
|
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
|
||||||
|
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
|
||||||
|
import { usePagedTools } from "@inspector/core/react/usePagedTools.js";
|
||||||
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
|
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
|
||||||
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
|
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
|
||||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||||
|
|
@ -2422,3 +2424,156 @@ describe("App paginated list pagination toggle (#1721)", () => {
|
||||||
expect(lastPush?.paginatedLists).toBeFalsy();
|
expect(lastPush?.paginatedLists).toBeFalsy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A background command runs through `runCommandInBackground`, which owns the
|
||||||
|
// rejection. Before #2049 the 17 `void runWithCommandAuthRecovery(...)` sites
|
||||||
|
// had no handler, so any non-auth failure of the wrapped operation became an
|
||||||
|
// unhandled rejection in the browser — invisible to the type-aware
|
||||||
|
// no-floating-promises rule (`void` is its sanctioned suppression) and fatal to
|
||||||
|
// `smoke:web:browser`, which hard-fails on exactly that signal.
|
||||||
|
describe("App background command rejections (#2049)", () => {
|
||||||
|
const LIST_FAILURE = new Error("list boom");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record process-level unhandled rejections for the duration of a test.
|
||||||
|
* Asserting on the captured list — rather than relying on vitest failing the
|
||||||
|
* whole run — is what attributes a regression to this test rather than to
|
||||||
|
* whichever file happened to be running when the rejection surfaced.
|
||||||
|
*/
|
||||||
|
const captureUnhandledRejections = () => {
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const onUnhandled = (reason: unknown) => {
|
||||||
|
seen.push(reason);
|
||||||
|
};
|
||||||
|
process.on("unhandledRejection", onUnhandled);
|
||||||
|
return {
|
||||||
|
seen,
|
||||||
|
stop: () => {
|
||||||
|
process.off("unhandledRejection", onUnhandled);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Node reports an unhandled rejection at the end of the turn, so drain the
|
||||||
|
// macrotask queue before asserting that none was reported.
|
||||||
|
const settleRejections = async () => {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clientInstances.length = 0;
|
||||||
|
notificationsMock.show.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore the module defaults the other blocks render against.
|
||||||
|
vi.mocked(useManagedTools).mockReturnValue({
|
||||||
|
tools: [{ name: "get_acts", inputSchema: { type: "object" } }],
|
||||||
|
error: null,
|
||||||
|
listChanged: false,
|
||||||
|
refresh: vi.fn().mockResolvedValue([]),
|
||||||
|
clearListChanged: clearToolsListChangedSpy,
|
||||||
|
});
|
||||||
|
vi.mocked(usePagedTools).mockReturnValue({
|
||||||
|
tools: [],
|
||||||
|
nextCursor: undefined,
|
||||||
|
pageCount: 0,
|
||||||
|
error: null,
|
||||||
|
loadPage: vi.fn(() =>
|
||||||
|
Promise.resolve({ tools: [], nextCursor: undefined }),
|
||||||
|
),
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<App />);
|
||||||
|
await user.click(screen.getByText("connect"));
|
||||||
|
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("does not leak an unhandled rejection when an all-pages Refresh fails", async () => {
|
||||||
|
vi.mocked(useManagedTools).mockReturnValue({
|
||||||
|
tools: [],
|
||||||
|
error: LIST_FAILURE,
|
||||||
|
listChanged: false,
|
||||||
|
refresh: vi.fn().mockRejectedValue(LIST_FAILURE),
|
||||||
|
clearListChanged: clearToolsListChangedSpy,
|
||||||
|
});
|
||||||
|
const rejections = captureUnhandledRejections();
|
||||||
|
try {
|
||||||
|
const user = await connect();
|
||||||
|
await user.click(screen.getByText("refresh-tools"));
|
||||||
|
await settleRejections();
|
||||||
|
expect(rejections.seen).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
rejections.stop();
|
||||||
|
}
|
||||||
|
// The panel already renders the failure with a Retry, so no toast is added
|
||||||
|
// on top of it — that is the whole reason this site swallows the rejection.
|
||||||
|
expect(notificationsMock.show).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak an unhandled rejection when a paginated Refresh or Load-next-page fails", async () => {
|
||||||
|
vi.mocked(usePagedTools).mockReturnValue({
|
||||||
|
tools: [],
|
||||||
|
// A cursor is what makes Load-next-page actually fetch.
|
||||||
|
nextCursor: "cursor-1",
|
||||||
|
pageCount: 1,
|
||||||
|
error: LIST_FAILURE,
|
||||||
|
loadPage: vi.fn().mockRejectedValue(LIST_FAILURE),
|
||||||
|
clear: vi.fn(),
|
||||||
|
});
|
||||||
|
const rejections = captureUnhandledRejections();
|
||||||
|
try {
|
||||||
|
const user = await connect();
|
||||||
|
// Flipping the mode drives its own page-1 load, which fails too.
|
||||||
|
await user.click(screen.getByText("paginated-on"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText("refresh-tools"));
|
||||||
|
await user.click(screen.getByText("load-more-tools"));
|
||||||
|
await settleRejections();
|
||||||
|
expect(rejections.seen).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
rejections.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts a set-log-level failure instead of leaking it", async () => {
|
||||||
|
const rejections = captureUnhandledRejections();
|
||||||
|
try {
|
||||||
|
const user = await connect();
|
||||||
|
// Single assertion: the fake client IS an EventTarget, so narrowing it
|
||||||
|
// to that intersection needs no `as unknown as` detour.
|
||||||
|
const client = clientInstances[0] as EventTarget & {
|
||||||
|
setLoggingLevel: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
client.setLoggingLevel.mockRejectedValueOnce(new Error("no logging"));
|
||||||
|
|
||||||
|
await user.click(screen.getByText("set-level"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Failed to set log level",
|
||||||
|
message: "no logging",
|
||||||
|
color: "red",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await settleRejections();
|
||||||
|
expect(rejections.seen).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
rejections.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2066,6 +2066,46 @@ function App() {
|
||||||
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
|
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget form of {@link runWithCommandAuthRecovery}, for a command
|
||||||
|
* whose caller has nothing to await (a click handler, a mode toggle).
|
||||||
|
*
|
||||||
|
* The wrapper rethrows anything that is not an `AuthRecoveryRequiredError`,
|
||||||
|
* so a bare `void runWithCommandAuthRecovery(...)` turns every non-auth
|
||||||
|
* failure — a transport error, a rejected `tools/list` — into an unhandled
|
||||||
|
* rejection in the browser (#2049). Routing every background command through
|
||||||
|
* here is what keeps a new call site from reintroducing that gap by
|
||||||
|
* omission: there is no `void` to forget.
|
||||||
|
*
|
||||||
|
* `errorTitle` picks the reporting, and the choice is per call site:
|
||||||
|
*
|
||||||
|
* - **Omit it** when the operation's own state already renders the failure —
|
||||||
|
* the list loads, whose managed/paged stores record the error and whose
|
||||||
|
* panel shows it with a Retry. A toast there would only duplicate what the
|
||||||
|
* user is already looking at, so the rejection is swallowed deliberately.
|
||||||
|
* - **Pass one** when nothing else records the failure, and the user would
|
||||||
|
* otherwise see the command silently do nothing.
|
||||||
|
*/
|
||||||
|
const runCommandInBackground = useCallback(
|
||||||
|
(
|
||||||
|
operation: () => Promise<unknown>,
|
||||||
|
source: StepUpSource,
|
||||||
|
errorTitle?: string,
|
||||||
|
): void => {
|
||||||
|
void runWithCommandAuthRecovery(operation, source).catch(
|
||||||
|
(err: unknown) => {
|
||||||
|
if (!errorTitle) return;
|
||||||
|
notifications.show({
|
||||||
|
title: errorTitle,
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[runWithCommandAuthRecovery],
|
||||||
|
);
|
||||||
|
|
||||||
const resumePendingReauth = useCallback(
|
const resumePendingReauth = useCallback(
|
||||||
async (pending: PendingReauth) => {
|
async (pending: PendingReauth) => {
|
||||||
if (reauthResumeInProgressRef.current) {
|
if (reauthResumeInProgressRef.current) {
|
||||||
|
|
@ -3474,12 +3514,16 @@ function App() {
|
||||||
(level: LoggingLevel) => {
|
(level: LoggingLevel) => {
|
||||||
setCurrentLogLevel(level);
|
setCurrentLogLevel(level);
|
||||||
if (!inspectorClient) return;
|
if (!inspectorClient) return;
|
||||||
void runWithCommandAuthRecovery(
|
// Nothing else records a `logging/setLevel` failure, and the optimistic
|
||||||
|
// level above already moved — so report it, or the selector would sit on
|
||||||
|
// a level the server never accepted with no explanation.
|
||||||
|
runCommandInBackground(
|
||||||
() => inspectorClient.setLoggingLevel(level),
|
() => inspectorClient.setLoggingLevel(level),
|
||||||
"ambient",
|
"ambient",
|
||||||
|
"Failed to set log level",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[inspectorClient, runWithCommandAuthRecovery],
|
[inspectorClient, runCommandInBackground],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Modern era (#1629): no request is sent — the client stores the level and
|
// Modern era (#1629): no request is sent — the client stores the level and
|
||||||
|
|
@ -3502,51 +3546,39 @@ function App() {
|
||||||
// (the managed state lit it on `list_changed`; nothing else clears it in
|
// (the managed state lit it on `list_changed`; nothing else clears it in
|
||||||
// paginated mode) (#1721).
|
// paginated mode) (#1721).
|
||||||
clearToolsListChanged();
|
clearToolsListChanged();
|
||||||
void runWithCommandAuthRecovery(
|
runCommandInBackground(() => toolsPagination.onRefresh(), "ambient");
|
||||||
() => toolsPagination.onRefresh(),
|
|
||||||
"ambient",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
void runWithCommandAuthRecovery(() => refreshTools(), "ambient");
|
runCommandInBackground(() => refreshTools(), "ambient");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
paginatedLists,
|
paginatedLists,
|
||||||
toolsPagination,
|
toolsPagination,
|
||||||
refreshTools,
|
refreshTools,
|
||||||
clearToolsListChanged,
|
clearToolsListChanged,
|
||||||
runWithCommandAuthRecovery,
|
runCommandInBackground,
|
||||||
]);
|
]);
|
||||||
const onRefreshPrompts = useCallback(() => {
|
const onRefreshPrompts = useCallback(() => {
|
||||||
if (paginatedLists) {
|
if (paginatedLists) {
|
||||||
clearPromptsListChanged();
|
clearPromptsListChanged();
|
||||||
void runWithCommandAuthRecovery(
|
runCommandInBackground(() => promptsPagination.onRefresh(), "ambient");
|
||||||
() => promptsPagination.onRefresh(),
|
|
||||||
"ambient",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient");
|
runCommandInBackground(() => refreshPrompts(), "ambient");
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
paginatedLists,
|
paginatedLists,
|
||||||
promptsPagination,
|
promptsPagination,
|
||||||
refreshPrompts,
|
refreshPrompts,
|
||||||
clearPromptsListChanged,
|
clearPromptsListChanged,
|
||||||
runWithCommandAuthRecovery,
|
runCommandInBackground,
|
||||||
]);
|
]);
|
||||||
const onRefreshResources = useCallback(() => {
|
const onRefreshResources = useCallback(() => {
|
||||||
if (paginatedLists) {
|
if (paginatedLists) {
|
||||||
clearResourcesListChanged();
|
clearResourcesListChanged();
|
||||||
void runWithCommandAuthRecovery(
|
runCommandInBackground(() => resourcesPagination.onRefresh(), "ambient");
|
||||||
() => resourcesPagination.onRefresh(),
|
|
||||||
"ambient",
|
|
||||||
);
|
|
||||||
// Resource templates always use the managed (aggregate) path.
|
// Resource templates always use the managed (aggregate) path.
|
||||||
void runWithCommandAuthRecovery(
|
runCommandInBackground(() => refreshResourceTemplates(), "ambient");
|
||||||
() => refreshResourceTemplates(),
|
|
||||||
"ambient",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
void runWithCommandAuthRecovery(async () => {
|
runCommandInBackground(async () => {
|
||||||
await refreshResources();
|
await refreshResources();
|
||||||
await refreshResourceTemplates();
|
await refreshResourceTemplates();
|
||||||
}, "ambient");
|
}, "ambient");
|
||||||
|
|
@ -3557,7 +3589,7 @@ function App() {
|
||||||
refreshResources,
|
refreshResources,
|
||||||
refreshResourceTemplates,
|
refreshResourceTemplates,
|
||||||
clearResourcesListChanged,
|
clearResourcesListChanged,
|
||||||
runWithCommandAuthRecovery,
|
runCommandInBackground,
|
||||||
]);
|
]);
|
||||||
// The per-list sidebar toggle edits the server-wide `paginatedLists` setting:
|
// The per-list sidebar toggle edits the server-wide `paginatedLists` setting:
|
||||||
// optimistic override for an instant flip, live push so the managed state's
|
// optimistic override for an instant flip, live push so the managed state's
|
||||||
|
|
@ -3581,22 +3613,13 @@ function App() {
|
||||||
// Wrap in ambient auth recovery so a mid-session 401 triggers re-auth
|
// Wrap in ambient auth recovery so a mid-session 401 triggers re-auth
|
||||||
// rather than surfacing raw, matching the all-pages refresh path.
|
// rather than surfacing raw, matching the all-pages refresh path.
|
||||||
if (value) {
|
if (value) {
|
||||||
void runWithCommandAuthRecovery(
|
runCommandInBackground(() => loadToolsPage(undefined), "ambient");
|
||||||
() => loadToolsPage(undefined),
|
runCommandInBackground(() => loadPromptsPage(undefined), "ambient");
|
||||||
"ambient",
|
runCommandInBackground(() => loadResourcesPage(undefined), "ambient");
|
||||||
);
|
|
||||||
void runWithCommandAuthRecovery(
|
|
||||||
() => loadPromptsPage(undefined),
|
|
||||||
"ambient",
|
|
||||||
);
|
|
||||||
void runWithCommandAuthRecovery(
|
|
||||||
() => loadResourcesPage(undefined),
|
|
||||||
"ambient",
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
void runWithCommandAuthRecovery(() => refreshTools(), "ambient");
|
runCommandInBackground(() => refreshTools(), "ambient");
|
||||||
void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient");
|
runCommandInBackground(() => refreshPrompts(), "ambient");
|
||||||
void runWithCommandAuthRecovery(() => refreshResources(), "ambient");
|
runCommandInBackground(() => refreshResources(), "ambient");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void updateServerSettings(activeServerId, next).catch((err: unknown) => {
|
void updateServerSettings(activeServerId, next).catch((err: unknown) => {
|
||||||
|
|
@ -3625,34 +3648,24 @@ function App() {
|
||||||
refreshTools,
|
refreshTools,
|
||||||
refreshPrompts,
|
refreshPrompts,
|
||||||
refreshResources,
|
refreshResources,
|
||||||
runWithCommandAuthRecovery,
|
runCommandInBackground,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
// Wrap Load-next-page in ambient auth recovery too, so a paginated
|
// Wrap Load-next-page in ambient auth recovery too, so a paginated
|
||||||
// paginated fetch that hits a 401 recovers like the all-pages path (#1721).
|
// paginated fetch that hits a 401 recovers like the all-pages path (#1721).
|
||||||
const onLoadMoreTools = useCallback(
|
const onLoadMoreTools = useCallback(
|
||||||
() =>
|
() => runCommandInBackground(() => toolsPagination.onLoadMore(), "ambient"),
|
||||||
void runWithCommandAuthRecovery(
|
[toolsPagination, runCommandInBackground],
|
||||||
() => toolsPagination.onLoadMore(),
|
|
||||||
"ambient",
|
|
||||||
),
|
|
||||||
[toolsPagination, runWithCommandAuthRecovery],
|
|
||||||
);
|
);
|
||||||
const onLoadMorePrompts = useCallback(
|
const onLoadMorePrompts = useCallback(
|
||||||
() =>
|
() =>
|
||||||
void runWithCommandAuthRecovery(
|
runCommandInBackground(() => promptsPagination.onLoadMore(), "ambient"),
|
||||||
() => promptsPagination.onLoadMore(),
|
[promptsPagination, runCommandInBackground],
|
||||||
"ambient",
|
|
||||||
),
|
|
||||||
[promptsPagination, runWithCommandAuthRecovery],
|
|
||||||
);
|
);
|
||||||
const onLoadMoreResources = useCallback(
|
const onLoadMoreResources = useCallback(
|
||||||
() =>
|
() =>
|
||||||
void runWithCommandAuthRecovery(
|
runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"),
|
||||||
() => resourcesPagination.onLoadMore(),
|
[resourcesPagination, runCommandInBackground],
|
||||||
"ambient",
|
|
||||||
),
|
|
||||||
[resourcesPagination, runWithCommandAuthRecovery],
|
|
||||||
);
|
);
|
||||||
const toolsPaginationControls: ListPaginationControlsProps = {
|
const toolsPaginationControls: ListPaginationControlsProps = {
|
||||||
paginated: toolsPagination.paginated,
|
paginated: toolsPagination.paginated,
|
||||||
|
|
@ -3676,16 +3689,12 @@ function App() {
|
||||||
onLoadMore: onLoadMoreResources,
|
onLoadMore: onLoadMoreResources,
|
||||||
};
|
};
|
||||||
const onRefreshTasks = useCallback(() => {
|
const onRefreshTasks = useCallback(() => {
|
||||||
void runWithCommandAuthRecovery(() => refreshTasks(), "ambient")?.catch(
|
runCommandInBackground(
|
||||||
(err: unknown) => {
|
() => refreshTasks(),
|
||||||
notifications.show({
|
"ambient",
|
||||||
title: "Failed to refresh tasks",
|
"Failed to refresh tasks",
|
||||||
message: err instanceof Error ? err.message : String(err),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}, [refreshTasks, runWithCommandAuthRecovery]);
|
}, [refreshTasks, runCommandInBackground]);
|
||||||
|
|
||||||
const onClearLogs = useCallback(() => {
|
const onClearLogs = useCallback(() => {
|
||||||
if (!messageLogState) return;
|
if (!messageLogState) return;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue