Merge branch 'v2/main' into v2/fix/1914-surface-post-add-refresh-failure

Both lines changed the same two settings-write paths in App.tsx:

- #2089 (v2/main) rewrote the pagination toggle and the settings-draft
  flush around `useLastPersistedSettings`, so each write announces itself
  with `begin` and confirms with `landed`/`failed`.
- #1914 (this branch) made a failed post-write list reload reject with
  `ServerListReloadError` — the PUT landed, only reading the list back
  failed.

Resolved by treating a `ServerListReloadError` as a *landed* write in the
new model rather than a failed one: both paths now settle it exactly as
the success path does (so it becomes the rollback baseline and re-applies
itself when settled) and only the toast says the reload failed. Rolling
it back would put the UI, the live client and the modal's seed on a value
disk no longer holds — the very contradiction #2089 exists to prevent.

The two suites' new tests were interleaved by the conflict and are both
kept, plus one covering the combination: a modal save whose reload failed
is recorded as the landed write instead of rolled back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 19:37:27 -04:00
commit b1996d1093
11 changed files with 3185 additions and 230 deletions

View file

@ -8,11 +8,15 @@
# load. (The other clients don't hit this because none of them ship a nested
# .gitignore.)
#
# Crucially this file does NOT list `build` or `dist`, so both the prod runner
# (build/) and the SPA (dist/) are packed. The root "files" allowlist already
# restricts publishing to those two directories, so everything else in
# clients/web (src, configs, node_modules, coverage, storybook-static) stays out
# regardless — the entries below are just belt-and-suspenders.
# Crucially this file does NOT list `build`, `dist`, or `static`, so all three
# are packed: the prod runner (build/), the SPA (dist/), and the MCP Apps
# sandbox proxy page (static/sandbox_proxy.html — a committed source file, read
# from disk at runtime by server/sandbox-controller.ts as
# `<runner dir>/../static/sandbox_proxy.html`, so it must ship at exactly that
# path; #1859). The root "files" allowlist names those three directories and
# nothing else under clients/web, so the rest (src, configs, node_modules,
# coverage, storybook-static) stays out regardless — the entries below are just
# belt-and-suspenders.
node_modules
coverage
storybook-static

View file

@ -34,6 +34,14 @@ const NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [
// excluding it keeps Vite's dep scanner from chasing into the
// platform-specific binaries during dev startup.
"@napi-rs/keyring",
// `proper-lockfile` is reached only through `core/auth/node/file-lock.ts`
// — the secrets file's cross-process lock (#2082) — which the Hono
// `/api/servers` handlers pull in via `core/auth/node/file-secret-store.ts`.
// Same node-only import chain as `atomically` above, and the same reason:
// it is CJS with a `graceful-fs`/`signal-exit`/`retry` graph that Vite's
// dev scanner has no business walking. Note the tsup `external` lists do
// **not** cover this — they configure the production bundles, not `vite dev`.
"proper-lockfile",
] as const;
export function getViteBaseConfig() {

View file

@ -1655,6 +1655,9 @@ describe("App task wiring", () => {
type RootsFakeClient = EventTarget & {
setRoots: ReturnType<typeof vi.fn>;
getRoots: ReturnType<typeof vi.fn>;
// Close also pushes the settings it decided to apply, which is what the
// failed-save case asserts on (#2089).
setServerSettings: ReturnType<typeof vi.fn>;
};
const settingsWithRoots = (
@ -1745,6 +1748,99 @@ describe("App roots live-apply on settings-dialog close", () => {
);
expect(client.setRoots).not.toHaveBeenCalled();
});
it("applies the last persisted settings on close after a save failed (#2089)", async () => {
// `useSettingsDraft` keeps the draft when a save rejects, so closing the
// modal would push a value that never reached disk into the live client —
// undoing the rollback that reported the failure. Close has to apply what
// landed instead.
const user = userEvent.setup();
updateServerSettingsSpy.mockClear();
const failedDraft: InspectorServerSettings = {
...settingsWithRoots([{ uri: "file:///rejected" }]),
paginatedLists: true,
};
const client = await openSettingsForConnectedServer(failedDraft);
client.getRoots.mockReturnValue([]);
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
// One save lands, so the tracker has an account of disk…
const persistedSettings: InspectorServerSettings = {
...settingsWithRoots([]),
paginatedLists: false,
};
await act(async () => {
await draftOptions.onPersist("A", persistedSettings);
});
// …then the next one rejects, leaving the draft holding an unsaved edit.
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await act(async () => {
await draftOptions.onPersist("A", failedDraft).catch(() => undefined);
});
client.setServerSettings.mockClear();
await closeModal(user);
await waitFor(() => expect(client.setServerSettings).toHaveBeenCalled());
expect(client.setServerSettings).toHaveBeenLastCalledWith(
persistedSettings,
);
// …and the rejected roots edit is not advertised either.
expect(client.setRoots).not.toHaveBeenCalled();
});
it("restores the roots and log size too when a flushed save rejects (#2089)", async () => {
// Close flushes a pending save and live-applies the draft immediately —
// roots and the Network log size, not just the settings object. If that
// save then rejects, reconciling only `setServerSettings` would leave the
// server advertising roots that were never persisted.
const user = userEvent.setup();
updateServerSettingsSpy.mockClear();
const rejectedDraft: InspectorServerSettings = {
...settingsWithRoots([{ uri: "file:///rejected" }]),
maxFetchRequests: 10,
};
const client = await openSettingsForConnectedServer(rejectedDraft);
client.getRoots.mockReturnValue([]);
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
const persistedSettings = settingsWithRoots([]);
await act(async () => {
await draftOptions.onPersist("A", persistedSettings);
});
// Close applies the draft live — the state the flush leaves behind.
const fetchLog = fetchLogInstances.at(-1) as EventTarget & {
setMaxFetchRequests: ReturnType<typeof vi.fn>;
};
await closeModal(user);
await waitFor(() =>
expect(client.setRoots).toHaveBeenCalledWith([
{ uri: "file:///rejected" },
]),
);
expect(fetchLog.setMaxFetchRequests).toHaveBeenLastCalledWith(10);
client.getRoots.mockReturnValue([{ uri: "file:///rejected" }]);
client.setRoots.mockClear();
// …and only now does the flushed save reject.
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await act(async () => {
await draftOptions.onPersist("A", rejectedDraft).catch(() => undefined);
});
// The whole live surface goes back to what landed — settings, roots, and
// the log size, each of which close had moved to the rejected draft.
await waitFor(() => expect(client.setRoots).toHaveBeenCalledWith([]));
expect(client.setServerSettings).toHaveBeenLastCalledWith(
persistedSettings,
);
expect(fetchLog.setMaxFetchRequests).toHaveBeenLastCalledWith(
persistedSettings.maxFetchRequests,
);
});
});
describe("App history pin/replay", () => {
@ -2451,6 +2547,485 @@ describe("App paginated list pagination toggle (#1721)", () => {
expect(lastPush?.paginatedLists).toBeFalsy();
});
it("rolls back to the last write that landed, not to a stale list entry (#2089)", async () => {
// Two toggles. The first PUT lands but the list reload behind it fails, so
// the `servers` entry keeps describing disk as it was *before* it — which
// this suite models exactly, because `useServers` is mocked to return the
// same entry forever. The second PUT fails outright and rolls back.
//
// Reverting to the `servers` entry there lands on `false`, the value from
// before the first toggle, contradicting what is on disk. The baseline has
// to come from the write that landed instead.
//
// One failed toggle cannot distinguish the two baselines — they only differ
// once a write has landed that the list never caught up with — so the test
// needs the full sequence.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
"A",
expect.objectContaining({ paginatedLists: true }),
),
);
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await user.click(screen.getByText("paginated-off"));
// Back to the value the first toggle put on disk, not to the pre-toggle one.
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("rolls back to a write that landed while this one was in flight (#2089)", async () => {
// Overlapping toggles. The baseline cannot be captured when a write is
// issued: toggle 1 is still in flight when toggle 2 reads it, so toggle 2
// would close over `false` — and by the time toggle 2 fails, toggle 1 has
// landed `true` on disk. Rolling back to the captured value contradicts
// disk exactly as the stale-list case does; the baseline has to be resolved
// at failure time. The two writes are driven by deferred promises so the
// order — issue 1, issue 2, land 1, fail 2 — is exact rather than timed.
const user = userEvent.setup();
let landFirst: (() => void) | undefined;
let failSecond: ((err: Error) => void) | undefined;
const first = new Promise<void>((resolve) => {
landFirst = resolve;
});
const second = new Promise<void>((_resolve, reject) => {
failSecond = (err) => {
reject(err);
};
});
updateServerSettingsSpy
.mockImplementationOnce(() => first)
.mockImplementationOnce(() => second);
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledTimes(2),
);
await act(async () => {
landFirst?.();
await first;
});
await act(async () => {
failSecond?.(new Error("disk full"));
await second.catch(() => undefined);
});
// `true` is what the landed write put on disk — not the `false` the second
// toggle read before it got there.
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("re-applies a write that lands after an overlapping one failed (#2089)", async () => {
// The mirror of the test above, in the other settlement order: toggle 1
// (`true`) is still in flight when toggle 2 (`false`) fails, so the rollback
// resolves to the stale entry's `false` — correct at that instant, since
// nothing had landed. Toggle 1 then lands `true`. If its list reload failed
// too, no render will ever correct the UI, so the write that settles has to
// re-apply itself.
const user = userEvent.setup();
let landFirst: (() => void) | undefined;
let failSecond: ((err: Error) => void) | undefined;
const first = new Promise<void>((resolve) => {
landFirst = resolve;
});
const second = new Promise<void>((_resolve, reject) => {
failSecond = (err) => {
reject(err);
};
});
updateServerSettingsSpy
.mockImplementationOnce(() => first)
.mockImplementationOnce(() => second);
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledTimes(2),
);
// Fail the second write first, then land the first.
await act(async () => {
failSecond?.(new Error("disk full"));
await second.catch(() => undefined);
});
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false");
await act(async () => {
landFirst?.();
await first;
});
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("re-applies a settled modal save after a toggle failed first (#2089)", async () => {
// The mixed-writer version of the order above: the modal's flush is in
// flight with `paginatedLists: true` when the sidebar toggle fails and
// rolls back to the stale entry's `false`. The modal save then lands, so it
// is the settled state — and it has to re-apply itself for the same reason
// the toggle does, or disk holds `true` while the UI and the live client
// sit on `false`.
const user = userEvent.setup();
let landModal: (() => void) | undefined;
const modalWrite = new Promise<void>((resolve) => {
landModal = resolve;
});
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
updateServerSettingsSpy.mockImplementationOnce(() => modalWrite);
const persisted = draftOptions.onPersist("A", {
...settingsWithRoots([]),
paginatedLists: true,
});
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("false"),
);
await act(async () => {
landModal?.();
await persisted;
});
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("applies a settled write to the current client, not the one it started on (#2089)", async () => {
// A write can outlive a disconnect/reconnect to the *same* server: the id
// guard still passes, but the client captured when the write was issued has
// been destroyed and replaced by one built from the stale list entry. The
// settled value has to reach the live instance.
const user = userEvent.setup();
let landWrite: (() => void) | undefined;
const write = new Promise<void>((resolve) => {
landWrite = resolve;
});
updateServerSettingsSpy.mockImplementationOnce(() => write);
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
// Reconnect to the same server while that write is in flight. The transport
// drops, then the user reconnects — and `onToggleConnection` always
// rebuilds the client so the latest saved settings are picked up.
act(() => {
clientInstances[0].dispatchEvent(new Event("disconnect"));
});
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(2));
const replacement = clientInstances[1] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
replacement.setServerSettings.mockClear();
await act(async () => {
landWrite?.();
await write;
});
await waitFor(() =>
expect(replacement.setServerSettings).toHaveBeenCalledWith(
expect.objectContaining({ paginatedLists: true }),
),
);
});
it("reconciles after a modal save fails onto a write that landed first (#2089)", async () => {
// Reverse of the mixed-writer case above: the toggle lands while the modal
// save is still pending, so it is told it is not settled and applies
// nothing. The modal save then fails, which makes the toggle's value the
// account of disk — and nothing else is left to apply it.
const user = userEvent.setup();
let failModal: ((err: Error) => void) | undefined;
const modalWrite = new Promise<void>((_resolve, reject) => {
failModal = (err) => {
reject(err);
};
});
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
updateServerSettingsSpy.mockImplementationOnce(() => modalWrite);
const persisted = draftOptions
.onPersist("A", { ...settingsWithRoots([]), paginatedLists: false })
.catch(() => undefined);
// The toggle lands `true` while the modal save is still in flight.
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledTimes(2),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
client.setServerSettings.mockClear();
await act(async () => {
failModal?.(new Error("disk full"));
await persisted;
});
// The failure is what reconciles: a push carrying the landed value arrives
// only after it, since the landed write itself was not settled at the time.
await waitFor(() =>
expect(client.setServerSettings).toHaveBeenCalledWith(
expect.objectContaining({ paginatedLists: true }),
),
);
});
it("seeds the settings modal from the last write, not the stale entry (#2089)", async () => {
// The modal draft is the input to the *next* write, so seeding it from a
// `servers` entry that a failed list reload froze would send the superseded
// value back to disk the moment the user saves any unrelated field.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
await user.click(screen.getByText("paginated-on"));
await waitFor(() =>
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
"A",
expect.objectContaining({ paginatedLists: true }),
),
);
// `servers` is mocked to a fixed entry here, so it still reports the
// pre-toggle value — exactly what a failed reload leaves behind.
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
expect(draftOptions.resolveInitial("A")).toEqual(
expect.objectContaining({ paginatedLists: true }),
);
});
it("builds the next client from the last write, not the stale entry (#2089)", async () => {
// Connecting is where the whole connection is configured, and it read the
// `servers` entry directly — so a save that landed while list reads were
// failing (including one made from the modal for a server that was not
// connected at the time) was undone by the next connect, which rebuilt the
// client from the frozen entry.
const user = userEvent.setup();
renderWithMantine(<App />);
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
const saved: InspectorServerSettings = {
...settingsWithRoots([{ uri: "file:///saved" }]),
maxFetchRequests: 42,
};
await act(async () => {
await draftOptions.onPersist("A", saved);
});
// `servers` still reports the entry with no settings at all — what a failed
// reload leaves. The client must be built from the write that landed.
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const clientOptions = vi
.mocked(McpIndex.InspectorClient)
.mock.calls.at(-1)?.[1] as { roots?: { uri: string }[] } | undefined;
expect(clientOptions?.roots).toEqual([{ uri: "file:///saved" }]);
const fetchLogOptions = vi
.mocked(FetchLogModule.FetchRequestLogState)
.mock.calls.at(-1)?.[1] as { maxFetchRequests?: number } | undefined;
expect(fetchLogOptions?.maxFetchRequests).toBe(42);
});
it("lets a successful list read supersede a concrete rollback override (#2089)", async () => {
// A rollback sets the override to a value rather than clearing it, so the
// effect that drops it cannot key on the persisted boolean alone: if a
// later read reports the value the override replaced — an edit made outside
// the Inspector overtaking the write — neither that boolean nor the server
// id changes, and the UI would stay stuck on the override forever. A read
// rebuilds the list, so the entry object is the signal that supersedes it.
const user = userEvent.setup();
const previousUseServers = vi.mocked(useServers).getMockImplementation();
try {
const { rerender } = renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
// A write lands `true` while list reads are stale, then a toggle fails —
// leaving the override on `true` with the entry still reporting `false`.
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
await act(async () => {
await draftOptions.onPersist("A", {
...settingsWithRoots([]),
paginatedLists: true,
});
});
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
// Now a list read succeeds and still says `false` — same boolean, same
// server id, new entry object. It is authoritative and must win.
vi.mocked(useServers).mockReturnValue({
servers: [{ ...SERVER_A } as ServerEntry],
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: vi.fn(),
updateServer: vi.fn(),
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn(),
reorderServers: vi.fn(),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
rerender(<App />);
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent(
"false",
),
);
} finally {
if (previousUseServers) {
vi.mocked(useServers).mockImplementation(previousUseServers);
}
}
});
it("rolls back to a settings-modal save that landed, not just a toggle (#2089)", async () => {
// The two settings writers feed one record, and this is the half the
// toggle-driven tests cannot reach: `useSettingsDraft` is mocked for this
// file, so its `onPersist` never runs on its own and a regression that
// dropped `begin`/`landed` from the modal path would go unnoticed. The
// callback the App handed the hook is invoked directly instead.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
const saved: InspectorServerSettings = {
...settingsWithRoots([]),
paginatedLists: true,
};
// The save lands; the list read behind it is what stays stale, so `servers`
// (mocked to a fixed entry here) never reports it.
await act(async () => {
await draftOptions.onPersist("A", saved);
});
expect(updateServerSettingsSpy).toHaveBeenCalledWith("A", saved);
updateServerSettingsSpy.mockRejectedValueOnce(new Error("disk full"));
await user.click(screen.getByText("paginated-off"));
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
const client = clientInstances[0] as EventTarget & {
setServerSettings: ReturnType<typeof vi.fn>;
};
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
paginatedLists?: boolean;
};
expect(lastPush?.paginatedLists).toBe(true);
});
it("treats a modal save whose list reload failed as landed (#1914 + #2089)", async () => {
// A `ServerListReloadError` rejects *after* the PUT landed, so the save is
// on disk. It has to be recorded as the last landed write and applied like
// any other settled one — the failure path would roll the UI, the live
// client and the modal's seed back to a value disk no longer holds.
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
if (!draftOptions) throw new Error("useSettingsDraft was never called");
updateServerSettingsSpy.mockRejectedValueOnce(
new ServerListReloadError(
"The server was saved, but the server list could not be reloaded: disk full",
),
);
const saved: InspectorServerSettings = {
...settingsWithRoots([]),
paginatedLists: true,
};
await act(async () => {
await draftOptions.onPersist("A", saved).catch(() => undefined);
});
// `servers` is mocked to a fixed entry here — exactly what a failed reload
// leaves — so both of these read the record rather than the entry.
expect(draftOptions.resolveInitial("A")).toEqual(
expect.objectContaining({ paginatedLists: true }),
);
await waitFor(() =>
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
);
});
it("keeps the optimistic toggle when only the list reload failed (#1914)", async () => {
// The mirror of the test above. `refreshAfterWrite` rejects with a
// `ServerListReloadError` when the PUT landed and only reading the list

View file

@ -113,6 +113,7 @@ import { usePagedTools } from "@inspector/core/react/usePagedTools.js";
import { usePagedPrompts } from "@inspector/core/react/usePagedPrompts.js";
import { usePagedResources } from "@inspector/core/react/usePagedResources.js";
import { usePaginatedList } from "./hooks/usePaginatedList";
import { useLastPersistedSettings } from "./hooks/useLastPersistedSettings";
import { useValueChange } from "./hooks/useValueChange";
import type { ListPaginationControlsProps } from "./components/elements/ListPaginationControls/ListPaginationControls";
import { useManagedResourceTemplates } from "@inspector/core/react/useManagedResourceTemplates.js";
@ -1163,19 +1164,34 @@ function App() {
error: pagedResourcesLoadError,
loadPage: loadResourcesPage,
} = usePagedResources(inspectorClient, pagedResourcesState);
// What every settings write that landed on disk actually wrote, so a later
// failed write can be rolled back to that rather than to a `servers` entry
// frozen at the last successful list read (#2089).
const lastPersistedSettings = useLastPersistedSettings(servers);
// The active server's persisted paginated setting drives the display mode.
// The sidebar toggle edits it (optimistically, below) and persists it.
const activeServerEntry = servers.find((s) => s.id === activeServerId);
const persistedPaginatedLists =
servers.find((s) => s.id === activeServerId)?.settings?.paginatedLists ??
false;
activeServerEntry?.settings?.paginatedLists ?? false;
const [paginatedListsOverride, setPaginatedListsOverride] = useState<
boolean | null
>(null);
// Drop the optimistic override once the persisted value catches up (or the
// active server changes), so the persisted setting is the resting source.
useEffect(() => {
setPaginatedListsOverride(null);
}, [persistedPaginatedLists, activeServerId]);
//
// Keyed on the entry object rather than on the boolean it carries: the
// override can hold a *concrete* value (a rollback sets one instead of
// clearing), and if a later successful read reports the value that override
// replaced — an outside edit overtaking the write — the boolean and the
// server id are both unchanged and the UI stays stuck on it. Every successful
// read rebuilds the list, so the entry is the signal that supersedes it, and
// it subsumes the other two: the value is read off this entry, and switching
// servers selects a different one (#2089).
//
// Adjusted during render rather than in an effect, per this repo's rule
// against resetting state from render data in `useEffect` — the entry is
// referentially stable between reads, which is what `useValueChange` needs.
useValueChange(activeServerEntry, () => setPaginatedListsOverride(null));
const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists;
// The malformed-entry report is written by the aggregate walk's salvage. In
// paginated mode the tools/prompts/resources panels render the paged stores
@ -1719,11 +1735,18 @@ function App() {
const activeServerNameRef = useRef<string | undefined>(undefined);
const activeServerIdRef = useRef<string | undefined>(activeServerId);
const serversRef = useRef(servers);
// The live client, mirrored for the same reason: a settings write that
// outlives a disconnect/reconnect to the *same* server would otherwise push
// its value into the instance captured when it was issued — which has since
// been destroyed — while the replacement client, built from the stale list
// entry, keeps the value the write replaced (#2089).
const inspectorClientRef = useRef<InspectorClient | null>(inspectorClient);
useEffect(() => {
if (activeServer) activeServerNameRef.current = activeServer.name;
activeServerIdRef.current = activeServerId;
serversRef.current = servers;
}, [activeServer, activeServerId, servers]);
inspectorClientRef.current = inspectorClient;
}, [activeServer, activeServerId, servers, inspectorClient]);
// Last connection-level error message, surfaced as `data-error-message` on
// the InspectorView header so an automated driver can read *why* a connect
@ -1910,6 +1933,43 @@ function App() {
// rebound every time the active server (and its log state) changes.
const fetchLogRef = useRef<FetchRequestLogState | null>(null);
// Make the live client reflect one settings value, in full. Every path that
// decides what the active server's settings *are* goes through this: the
// settings modal's close (#1444), and each write's settled / rolled-back
// reconciliation (#2089). It is one function because the live surface is more
// than `setServerSettings` — a partial re-application would leave the Network
// log sized for, and the server advertised roots from, a value that is not on
// disk. Reads the client through its ref so a write outliving a reconnect
// applies to the instance that exists now.
const applyLiveServerSettings = useCallback(
(settings: InspectorServerSettings) => {
const client = inspectorClientRef.current;
if (!client) return;
// Settings the managed state reads at notification time
// (auto-refresh-on-list-changed) take effect without a reconnect (#1444).
// Connection-time inputs (transport, OAuth, timeouts) still only apply on
// the next connect.
client.setServerSettings(settings);
// Resize the Network log buffer live so a maxFetchRequests edit takes
// effect without a reconnect (shrinking trims immediately).
fetchLogRef.current?.setMaxFetchRequests(settings.maxFetchRequests);
// Root edits are diffed against what the client currently advertises
// (both cleaned) and notified only when they differ: `setRoots` fires
// `notifications/roots/list_changed`, which makes the server re-request
// `roots/list`.
const nextRoots = cleanRoots(settings.roots);
const currentRoots = cleanRoots(client.getRoots());
if (JSON.stringify(nextRoots) !== JSON.stringify(currentRoots)) {
void client.setRoots(nextRoots).catch(() => {
// setRoots swallows notification failures internally; a throw here
// only means the client is mid-teardown — the persisted roots will
// re-advertise on the next connect, so nothing to surface.
});
}
},
[],
);
// Flush the pre-redirect Network log to backend storage, keyed by the OAuth
// authId carried in the authorization URL's `state`. Runs synchronously from
// `BrowserNavigation` right before `window.location.href`, so the keepalive
@ -2465,7 +2525,16 @@ function App() {
);
// The settings node persisted in mcp.json for this server — distinct
// from the InspectorClient options we're about to derive from it.
const savedSettings = server.settings;
//
// Through the tracker, not `server.settings` directly: the entry only
// advances on a successful list read, so a write that landed while reads
// were failing — including one made from the settings modal for a server
// that was not connected at the time — would otherwise be undone at the
// next connect, which builds the client from that frozen entry. This is
// the one place the whole connection is configured, so every construction
// path (connect, reconnect, OAuth resume) goes through it (#2089).
const savedSettings =
lastPersistedSettings.resolve(server.id) ?? server.settings;
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
// Flatten the persisted settings into the InspectorClient options shape.
@ -2637,6 +2706,7 @@ function App() {
onBeforeOAuthRedirect,
clientConfig,
newAppElicitationSession,
lastPersistedSettings,
],
);
@ -3734,9 +3804,12 @@ function App() {
setPaginatedListsOverride(value);
const current = servers.find((s) => s.id === activeServerId);
if (!current || activeServerId === undefined) return;
const prevSettings = current.settings ?? EMPTY_SETTINGS;
// Not `current.settings` directly: that entry only advances on a
// successful list read, so once one has failed it describes disk as it
// was *before* the writes made since. Build on the last write known to
// have landed while that is the fresher account (#2089).
const next: InspectorServerSettings = {
...prevSettings,
...(lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS),
paginatedLists: value,
};
inspectorClient?.setServerSettings(next);
@ -3761,35 +3834,91 @@ function App() {
// server's rehydrated secrets, so it can trigger the pending
// plaintext-to-encrypted upgrade even though the user only toggled
// pagination (#1950 review r22).
// Announced before the request goes out, so two toggles in flight at once
// are ordered by when they were issued rather than by which one's list
// reload finished first (#2089).
const write = lastPersistedSettings.begin(activeServerId);
// This value is on disk now. Remember it as the rollback baseline for
// whatever is written next, since the `servers` entry it was derived
// from will keep describing the old value if the reload behind this
// write — or any later one — fails (#2089).
//
// Re-apply it when this write is the settled one: an overlapping toggle
// that failed *first* rolled the UI and the live client back to a
// baseline this write has since replaced, and if the list read behind
// this write failed too, nothing else would ever correct them. Through
// the *current* client, not this continuation's closure: a reconnect to
// the same server passes the id check while the captured instance is
// already destroyed.
const settlePaginationWrite = () => {
const settled = write.landed(next);
if (settled && activeServerIdRef.current === activeServerId) {
setPaginatedListsOverride(next.paginatedLists ?? false);
applyLiveServerSettings(next);
}
};
void refreshingPersist(updateServerSettings, refreshInitialConfig)(
activeServerId,
next,
).catch((err: unknown) => {
// A ServerListReloadError means the PUT landed and only reading the
// list back failed, so the new setting IS on disk (#1914). Rolling
// back here would put the UI and the live client on the *old* value
// and contradict it — report it and leave the optimistic state alone.
const reloadFailed = err instanceof ServerListReloadError;
if (!reloadFailed) {
// Persist failed: revert the optimistic override (the effect only
// clears it when the persisted value changes, which won't happen here)
// and roll the live client setting back, so the UI and client reflect
// the value that's actually on disk rather than the failed edit (#1721).
setPaginatedListsOverride(null);
inspectorClient?.setServerSettings(prevSettings);
}
notifications.show({
title: reloadFailed
? "Pagination setting saved, but the server list did not reload"
: "Failed to save pagination setting",
message: err instanceof Error ? err.message : String(err),
color: "red",
)
.then(settlePaginationWrite)
.catch((err: unknown) => {
// A `ServerListReloadError` means the PUT landed and only reading the
// list back failed, so the new setting IS on disk (#1914). That is a
// landed write, not a failed one: rolling back would put the UI and
// the live client on the *old* value and contradict disk. Settle it
// exactly as the success path does and report only the failed reload.
if (err instanceof ServerListReloadError) {
settlePaginationWrite();
notifications.show({
title:
"Pagination setting saved, but the server list did not reload",
message: err.message,
color: "red",
});
return;
}
// This write is over and never reached disk, so it stops counting as
// in flight: an earlier write still running is the settled state once
// it lands, and is what re-applies the UI this rollback is about to
// set (#2089).
write.failed();
// Persist failed: revert the optimistic override and roll the live
// client setting back, so the UI and client reflect the value that's
// actually on disk rather than the failed edit (#1721).
//
// The baseline is resolved *here*, not captured when this write was
// issued: another toggle can land in between, and its value is what
// disk holds by the time this one fails. The override is set to that
// baseline rather than cleared, because clearing it falls back to
// `persistedPaginatedLists` — read from a `servers` entry that may be
// stale, showing the same wrong value from the other side (#2089).
//
// Applied only while this write's server is still the active one: the
// override is a single app-wide boolean and the live client belongs to
// whichever server is connected now, so a rejection arriving after the
// user switched would render this server's value on another one.
// The client is taken from the ref for the same reason the success
// path does: a reconnect to the same server passes the id check while
// this continuation's captured instance is already destroyed.
const baseline =
lastPersistedSettings.resolve(activeServerId) ?? EMPTY_SETTINGS;
if (activeServerIdRef.current === activeServerId) {
setPaginatedListsOverride(baseline.paginatedLists ?? false);
applyLiveServerSettings(baseline);
}
notifications.show({
title: "Failed to save pagination setting",
message: err instanceof Error ? err.message : String(err),
color: "red",
});
});
});
},
[
servers,
activeServerId,
lastPersistedSettings,
applyLiveServerSettings,
inspectorClient,
updateServerSettings,
refreshInitialConfig,
@ -4197,8 +4326,12 @@ function App() {
flush: flushSettingsDraft,
} = useSettingsDraft<InspectorServerSettings>({
targetId: settingsModalTargetId,
resolveInitial: (id) =>
servers.find((s) => s.id === id)?.settings ?? EMPTY_SETTINGS,
// Through the tracker rather than off `servers` directly: after a write
// lands whose list reload failed, that entry still describes the previous
// value, so seeding from it and saving an unrelated field would write the
// superseded value straight back to disk (#2089). `resolve` falls back to
// the entry when nothing has landed, so the ordinary case is unchanged.
resolveInitial: (id) => lastPersistedSettings.resolve(id) ?? EMPTY_SETTINGS,
// The saved settings may include an OAuth client secret or a stdio `env:`
// value, and writing one can change what the secrets file *is* — the first
// save under a newly-set passphrase re-encrypts a pre-existing plaintext
@ -4206,7 +4339,60 @@ function App() {
// unit rather than an inline `await …; refresh()` because this file is
// outside the coverage gate, so wiring written here is tested by nothing
// (#1950 review r17).
onPersist: refreshingPersist(updateServerSettings, refreshInitialConfig),
onPersist: async (id: string, value: InspectorServerSettings) => {
// Announced before the request, like the toggle's own write: the debounced
// flush can put two saves for one server in flight, and the later-issued
// one describes disk however the two happen to finish (#2089).
const write = lastPersistedSettings.begin(id);
const settleSettingsWrite = () => {
const settled = write.landed(value);
if (settled && activeServerIdRef.current === id) {
setPaginatedListsOverride(value.paginatedLists ?? false);
applyLiveServerSettings(value);
}
};
try {
await refreshingPersist(updateServerSettings, refreshInitialConfig)(
id,
value,
);
} catch (err) {
// A `ServerListReloadError` means the PUT landed and only reading the
// list back failed, so this write did reach disk (#1914): record it
// like the success path below instead of rolling anything back, and
// rethrow so `onError` can say the reload — not the save — failed.
if (err instanceof ServerListReloadError) {
settleSettingsWrite();
throw err;
}
// Stop counting as in flight before the rejection goes on to
// `onError` — an earlier write still running settles the state once it
// lands, and cannot know that while this one looks pending (#2089).
write.failed();
// A write that landed while this one was pending was told it was not
// settled and so applied nothing; this save never reached disk, so that
// write is the account of it and nothing else will re-apply it. Same
// reconciliation the toggle's own failure path does.
const baseline = lastPersistedSettings.resolve(id);
if (baseline && activeServerIdRef.current === id) {
setPaginatedListsOverride(baseline.paginatedLists ?? false);
applyLiveServerSettings(baseline);
}
throw err;
}
// Recorded for the same reason the pagination toggle records its own
// write: this is what disk holds now, and the `servers` entry it was
// edited from will keep describing the previous value for as long as list
// reads keep failing. Both settings writers feed the same per-server
// record, so a rollback in either takes the most recent write for that
// server, not just the most recent write *of its own kind*.
//
// And the reconciliation is shared too, for the mixed-writer overlap: a
// toggle failing while this save is in flight rolls the UI and the live
// client back to a baseline this save then replaces on disk, so a settled
// save has to re-apply itself exactly as a settled toggle does (#2089).
settleSettingsWrite();
},
// Surface failures via toast — the modal usually closes
// immediately on user dismiss, so a silent fail-on-flush would
// leave the user thinking their last edits saved when they
@ -4346,25 +4532,20 @@ function App() {
settingsModalTargetId === activeServerId &&
settingsDraft
) {
// Push the edited settings onto the live client so settings the managed
// state reads at notification time (auto-refresh-on-list-changed) take
// effect without a reconnect (#1444). Connection-time inputs (transport,
// OAuth, timeouts) still only apply on the next connect.
inspectorClient.setServerSettings(settingsDraft);
// Resize the Network log buffer live so a maxFetchRequests edit takes
// effect without a reconnect (shrinking trims immediately). Connect-time
// construction also reads this, so a reconnect would apply it anyway —
// this just makes the toast→adjust flow responsive.
fetchLogRef.current?.setMaxFetchRequests(settingsDraft.maxFetchRequests);
const nextRoots = cleanRoots(settingsDraft.roots);
const currentRoots = cleanRoots(inspectorClient.getRoots());
if (JSON.stringify(nextRoots) !== JSON.stringify(currentRoots)) {
void inspectorClient.setRoots(nextRoots).catch(() => {
// setRoots swallows notification failures internally; a throw here
// only means the client is mid-teardown — the persisted roots will
// re-advertise on the next connect, so nothing to surface.
});
}
// Normally the draft is what the user just saved, so it is also what is
// on disk. When this server's last write *rejected* it is not: the draft
// still holds the failed edit, and applying it here would contradict disk
// and undo the rollback that reported the failure — so apply what landed
// instead. `EMPTY_SETTINGS`, not the draft, when nothing has ever landed
// for this server: falling back to the draft would re-apply the very
// values that failed (#2089).
const applied = lastPersistedSettings.lastWriteFailed(
settingsModalTargetId,
)
? (lastPersistedSettings.resolve(settingsModalTargetId) ??
EMPTY_SETTINGS)
: settingsDraft;
applyLiveServerSettings(applied);
}
setSettingsModalTargetId(undefined);
}, [
@ -4373,6 +4554,8 @@ function App() {
settingsModalTargetId,
activeServerId,
settingsDraft,
lastPersistedSettings,
applyLiveServerSettings,
]);
// The Resources screen needs `isSubscribed` to flip the Subscribe button

View file

@ -0,0 +1,225 @@
import { describe, it, expect } from "vitest";
import type {
InspectorServerSettings,
ServerEntry,
} from "@inspector/core/mcp/types.js";
import { renderWithMantine } from "../test/renderWithMantine";
import {
useLastPersistedSettings,
type LastPersistedSettings,
} from "./useLastPersistedSettings";
const settings = (
overrides: Partial<InspectorServerSettings> = {},
): InspectorServerSettings => ({
headers: [],
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: 0,
autoRefreshOnListChanged: false,
paginatedLists: false,
maxFetchRequests: 0,
roots: [],
...overrides,
});
/**
* One `servers` entry, a fresh object per call — most of these tests turn on
* object identity, so a shared fixture would defeat them. A list rebuilt from
* fresh calls is what a successful `GET /api/servers` produces.
*/
const entry = (
id: string,
settingsNode?: InspectorServerSettings,
): ServerEntry => ({
id,
name: id,
config: { type: "stdio", command: "node" },
connection: { status: "disconnected" },
settings: settingsNode,
});
interface Harness {
api: LastPersistedSettings;
/** Deliver a new server list, as a successful list read would. */
setServers: (next: ServerEntry[]) => void;
}
/**
* Hands the hook's API back to the test. The hook holds refs, so it has to be
* read from inside a rendered component; `setServers` re-renders it with a new
* list the way `useServers` does after a read.
*/
function harness(initial: ServerEntry[]): Harness {
let api: LastPersistedSettings | undefined;
function Probe({ servers }: { servers: ServerEntry[] }) {
api = useLastPersistedSettings(servers);
return null;
}
const { rerender } = renderWithMantine(<Probe servers={initial} />);
if (!api) throw new Error("hook did not render");
return {
api,
setServers: (next) => rerender(<Probe servers={next} />),
};
}
describe("useLastPersistedSettings", () => {
it("reads the list entry when no write has landed for that server", () => {
const onDisk = settings({ paginatedLists: true });
const { api } = harness([entry("A", onDisk)]);
expect(api.resolve("A")).toBe(onDisk);
});
it("returns nothing when the list has no settings and nothing landed", () => {
const { api } = harness([entry("A")]);
expect(api.resolve("A")).toBeUndefined();
});
it("returns the recorded write while the entry has not been re-read", () => {
const { api } = harness([entry("A", settings())]);
const written = settings({ paginatedLists: true });
api.begin("A").landed(written);
expect(api.resolve("A")).toBe(written);
});
it("ignores a record made for a different server", () => {
const onDiskB = settings();
const { api } = harness([entry("A"), entry("B", onDiskB)]);
api.begin("A").landed(settings({ paginatedLists: true }));
expect(api.resolve("B")).toBe(onDiskB);
});
it("keeps each server's record, so a save on B doesn't discard A's", () => {
// The settings modal can be opened for any server, connected or not, so
// this sequence is reachable: A's write lands while list reads are failing,
// B is saved from the modal, and only then does a write on A fail. A single
// slot would have lost A's baseline and reproduced #2089.
const { api } = harness([entry("A", settings()), entry("B", settings())]);
const writtenA = settings({ paginatedLists: true });
api.begin("A").landed(writtenA);
api.begin("B").landed(settings({ autoRefreshOnListChanged: true }));
expect(api.resolve("A")).toBe(writtenA);
});
it("stops trusting the record once a fresh list read replaces the entry", () => {
const { api, setServers } = harness([entry("A", settings())]);
api.begin("A").landed(settings({ paginatedLists: true }));
// A successful `GET /api/servers` rebuilds the list, so the entry is a new
// object even when its values are unchanged. That is the signal the list
// has caught up, and it is authoritative again from here.
const fresh = settings();
setServers([entry("A", fresh)]);
expect(api.resolve("A")).toBe(fresh);
});
it("pairs the record with the entry as of completion, not of issue", () => {
// A list read that lands while the write is in flight must not be mistaken
// for one that happened after it. The pairing is taken in `landed`, so the
// read below — which the write outlives — does not invalidate the record.
const { api, setServers } = harness([entry("A", settings())]);
const write = api.begin("A");
setServers([entry("A", settings())]);
const written = settings({ paginatedLists: true });
write.landed(written);
expect(api.resolve("A")).toBe(written);
});
it("keeps only the most recent write for a server", () => {
const { api } = harness([entry("A", settings())]);
api.begin("A").landed(settings({ paginatedLists: true }));
const later = settings({ autoRefreshOnListChanged: true });
api.begin("A").landed(later);
expect(api.resolve("A")).toBe(later);
});
it("lets the later-issued write win however the two finish", () => {
// `updateServerSettings` waits for a list read after its PUT, so an older
// write with a slow read can complete last. Ordering by completion would
// then record a value that a newer write has already replaced on disk, and
// the next failed write would roll back to it.
const { api } = harness([entry("A", settings())]);
const first = api.begin("A");
const second = api.begin("A");
const newest = settings({ paginatedLists: true });
second.landed(newest);
first.landed(settings({ paginatedLists: false }));
expect(api.resolve("A")).toBe(newest);
});
it("reports settled only for the last write issued", () => {
// The flag is what tells a caller it is safe to re-apply the value to the
// UI: while a later write is still in flight, that write will describe disk
// when it reports, and re-applying now would fight it.
const { api } = harness([entry("A", settings())]);
const first = api.begin("A");
expect(first.landed(settings({ paginatedLists: true }))).toBe(true);
const second = api.begin("A");
const third = api.begin("A");
expect(second.landed(settings({ paginatedLists: false }))).toBe(false);
expect(third.landed(settings({ paginatedLists: true }))).toBe(true);
// A superseded straggler is neither recorded nor settled.
expect(second.landed(settings({ paginatedLists: false }))).toBe(false);
});
it("treats an earlier write as settled once the later one has failed", () => {
// The order that leaves the UI wrong: the later write fails first, rolling
// the UI back to a baseline the earlier write then replaces on disk. Once
// the failed write stops counting as in flight, the earlier one settles and
// its caller can re-apply the value.
const { api } = harness([entry("A", settings())]);
const first = api.begin("A");
const second = api.begin("A");
second.failed();
const written = settings({ paginatedLists: true });
expect(first.landed(written)).toBe(true);
expect(api.resolve("A")).toBe(written);
});
it("does not let a straggler resurrect a record a fresh read dropped", () => {
// The high-water mark outlives the record, so an earlier write reporting
// after `resolve` invalidated things cannot reinstate a superseded value.
const { api, setServers } = harness([entry("A", settings())]);
const first = api.begin("A");
api.begin("A").landed(settings({ paginatedLists: true }));
const fresh = settings();
setServers([entry("A", fresh)]);
expect(api.resolve("A")).toBe(fresh);
first.landed(settings({ paginatedLists: false }));
expect(api.resolve("A")).toBe(fresh);
});
it("reports a failed last write per server, ordered by issue", () => {
// The settings modal's draft survives a rejected save, so its owner has to
// know whether what the user last tried to save is on disk. A failure on B
// says nothing about A, and a straggling failure from an older write does
// not overrule a newer write that succeeded.
const { api } = harness([entry("A", settings()), entry("B", settings())]);
expect(api.lastWriteFailed("A")).toBe(false);
const firstA = api.begin("A");
const secondA = api.begin("A");
secondA.landed(settings({ paginatedLists: true }));
firstA.failed();
expect(api.lastWriteFailed("A")).toBe(false);
api.begin("B").failed();
expect(api.lastWriteFailed("B")).toBe(true);
expect(api.lastWriteFailed("A")).toBe(false);
// …and a newer failure on A does count, until a newer write lands.
api.begin("A").failed();
expect(api.lastWriteFailed("A")).toBe(true);
api.begin("A").landed(settings());
expect(api.lastWriteFailed("A")).toBe(false);
});
it("returns nothing when the server has left the list entirely", () => {
const { api, setServers } = harness([entry("A", settings())]);
api.begin("A").landed(settings({ paginatedLists: true }));
setServers([]);
expect(api.resolve("A")).toBeUndefined();
});
});

View file

@ -0,0 +1,216 @@
import { useCallback, useEffect, useRef } from "react";
import type {
InspectorServerSettings,
ServerEntry,
} from "@inspector/core/mcp/types.js";
/**
* Remembers, per server, the last settings write known to have reached disk, so
* a *later* failed write can be rolled back to what is actually persisted
* rather than to a `servers` entry that has gone stale (#2089).
*
* The `servers` list only advances when a `GET /api/servers` succeeds. Once a
* list read fails, every entry in it keeps describing disk *as of the last
* successful read* — while writes issued since then have changed disk. A
* rollback that reads its baseline from such an entry reverts to a value
* nothing on disk holds, and the UI contradicts the file until the next
* successful read. The reproduction is two toggles: the first write lands but
* its list reload fails, the second write fails outright and reverts to the
* pre-first-toggle value.
*
* Records are kept **per server id**, not in a single slot. The settings modal
* can be opened for any server, independently of which one is connected, so a
* single slot would let a save on B discard the baseline a later failed write
* on A still needs — reproducing the very bug this hook exists to prevent.
*
* ## Which of two overlapping writes wins
*
* A write is announced with `begin` when it is *issued* and confirmed with
* `landed` when it completes, and a confirmation is ignored if a later-issued
* write for that server has already confirmed. Completion order cannot be used
* for this: `updateServerSettings` waits for a list read after its PUT, so an
* older write with a slow read can finish last and would otherwise overwrite
* the record with a value that is no longer on disk.
*
* Issue order is a proxy for disk order, not a proof of it — HTTP does not
* promise that two in-flight PUTs arrive in the order they were sent. It is the
* best signal available on this side: the backend serializes writes to the
* catalog, but neither the route nor `useServers` returns a revision the client
* could order by. If one is added later, this is where it replaces the counter.
*
* ## When a record stops being believed
*
* A record is paired with the `servers` entry for that server **as of when the
* write completed**, and is dropped once the list carries a different entry
* object: a successful read rebuilds the list, so a fresh object is the signal
* that the entry has caught up with — or been overtaken by (an edit made
* outside the Inspector) — our write, and from there the entry is authoritative
* again. The pairing is taken at completion rather than at issue because a read
* that lands *while the write is in flight* would otherwise be mistaken for one
* that happened after it.
*
* One case the client cannot decide is left: a background read whose response
* was generated before the write landed but which arrives after it still looks
* newer here, and the record is dropped in its favour. That needs the same
* missing revision as above. What the fallback yields in that case is the
* freshest successful read — the same value the rest of the UI renders from —
* rather than an arbitrary one.
*
* The list and the records are held in refs rather than state deliberately:
* nothing renders from either, they are read only inside callbacks at the
* moment a write is issued, completes, or fails, and holding them in state
* would re-render the whole composition root on every settings save.
*/
export interface LastPersistedSettings {
/**
* Announce a settings write for `serverId` at the moment it is **issued**.
* Confirm it with the returned handle once it completes.
*/
begin: (serverId: string) => SettingsWrite;
/**
* The best available answer to "what is on disk for this server", **as of the
* moment it is called**. Returns the recorded write while the list still
* carries the entry that write was paired with, otherwise that server's
* current entry settings, and `undefined` when the list has no such entry (or
* one carrying no settings) and no write has landed for it this session.
*
* Both the record and the list are read live, so calling this in a failure
* handler answers for the state at failure time. Do not hoist the result into
* a variable at the point the write is issued and reuse it on rejection: an
* overlapping write that landed in between would make it stale, which is the
* same class of bug as reading the baseline from a stale `servers` entry.
*/
resolve: (serverId: string) => InspectorServerSettings | undefined;
/**
* Whether the most recently **issued** write that has reported for this
* server ended in failure — i.e. whatever the user last tried to save is not
* what disk holds. A caller holding an edit buffer for that server (the
* settings modal's draft) uses it to know its buffer is unpersisted, and to
* apply `resolve` instead of the buffer.
*
* Ordered by issue, not by arrival, for the same reason the record is: a
* straggling failure from an older write does not overwrite the outcome of a
* newer one, and outcomes are kept per server so a failure on B says nothing
* about A.
*/
lastWriteFailed: (serverId: string) => boolean;
}
export interface SettingsWrite {
/**
* Record that this write reached disk. A no-op if a write issued after this
* one has already been confirmed for the same server.
*
* Returns whether this write is now the **settled** state for that server —
* recorded, with no later write still in flight to describe disk after it.
* Callers that own UI derived from the setting use it to re-apply the value:
* an overlapping write that failed *first* will have rolled that UI back to a
* baseline this write has since replaced, and if the list read behind this
* one failed too, nothing else would ever correct it.
*/
landed: (written: InspectorServerSettings) => boolean;
/**
* Report that this write is over without reaching disk, so it stops counting
* as in flight. An earlier write that lands afterwards is then the settled
* state — which is exactly the order that leaves the UI on a rolled-back
* value nothing on disk holds.
*/
failed: () => void;
}
interface WriteRecord {
written: InspectorServerSettings;
entry: ServerEntry | undefined;
}
/**
* @param servers the current server list, as rendered
*/
export function useLastPersistedSettings(
servers: ServerEntry[],
): LastPersistedSettings {
const recordsRef = useRef<Map<string, WriteRecord>>(new Map());
const nextSequenceRef = useRef(0);
// Highest issue order confirmed per server. Kept apart from the records so a
// record dropped by `resolve` can't be resurrected by a straggler that was
// issued earlier: the high-water mark outlives the record it produced.
const confirmedRef = useRef<Map<string, number>>(new Map());
// Issue orders still in flight per server, so a write that lands can tell
// whether it is the last word or whether something later will still report.
// A write leaves this set through `landed` or `failed` alike.
const pendingRef = useRef<Map<string, Set<number>>>(new Map());
// Outcome of the latest-issued write that has reported, per server. Kept
// apart from the record because a *failed* write leaves no record but is
// still the answer to "is the user's last attempt on disk?".
const outcomesRef = useRef<Map<string, { sequence: number; ok: boolean }>>(
new Map(),
);
// Read the list through a ref so a write pairs with the entry as it stands
// when it finishes, not the one captured when it was issued. Mirrored in an
// effect rather than assigned during render: writing a ref mid-render is an
// error under `react-hooks/refs`, and every reader runs from a settled
// promise or an event handler, long after the commit.
const serversRef = useRef(servers);
useEffect(() => {
serversRef.current = servers;
}, [servers]);
const begin = useCallback((serverId: string): SettingsWrite => {
const sequence = ++nextSequenceRef.current;
const pending = pendingRef.current.get(serverId) ?? new Set<number>();
pending.add(sequence);
pendingRef.current.set(serverId, pending);
const settle = (ok: boolean) => {
pending.delete(sequence);
// Keep the outcome of the latest-issued write that has reported, so an
// older straggler cannot overwrite a newer one's verdict.
const previous = outcomesRef.current.get(serverId);
if (!previous || previous.sequence <= sequence) {
outcomesRef.current.set(serverId, { sequence, ok });
}
};
return {
landed: (written: InspectorServerSettings) => {
settle(true);
const confirmed = confirmedRef.current.get(serverId) ?? 0;
// A write issued after this one already reported; it describes disk
// more recently, whichever of the two happened to finish first.
if (confirmed > sequence) return false;
confirmedRef.current.set(serverId, sequence);
const entry = serversRef.current.find((s) => s.id === serverId);
recordsRef.current.set(serverId, { written, entry });
// Settled only while nothing later is still in flight — such a write
// will describe disk once it reports, so re-applying this value in the
// meantime would fight it.
for (const other of pending) if (other > sequence) return false;
return true;
},
failed: () => settle(false),
};
}, []);
const lastWriteFailed = useCallback(
(serverId: string) => outcomesRef.current.get(serverId)?.ok === false,
[],
);
const resolve = useCallback(
(serverId: string): InspectorServerSettings | undefined => {
const entry = serversRef.current.find((s) => s.id === serverId);
const current = recordsRef.current.get(serverId);
if (!current) return entry?.settings;
// Identity, not deep equality: the question is whether the list has been
// re-read since the write, not whether the values happen to match.
if (current.entry !== entry) {
// Superseded — drop it rather than re-checking a dead record on every
// later write for this server.
recordsRef.current.delete(serverId);
return entry?.settings;
}
return current.written;
},
[],
);
return { begin, resolve, lastWriteFailed };
}

View file

@ -878,7 +878,9 @@ describe("useServers", () => {
fetchFn: async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (url.endsWith("/api/servers/events")) {
return { ok: true, body: null } as unknown as Response;
// A real Response constructed from `null` has a null `.body`,
// so the guard is exercised through the actual Response API.
return new Response(null, { status: 200 });
}
return h.fetchFn(url, init);
},
@ -897,12 +899,14 @@ describe("useServers", () => {
fetchFn: async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (url.endsWith("/api/servers/events")) {
const body = {
getReader: () => ({
read: () => Promise.reject(new Error("stream broke")),
}),
};
return { ok: true, body } as unknown as Response;
// A real stream whose first pull throws — `reader.read()` then
// rejects exactly as a broken network body would, with no cast.
const body = new ReadableStream<Uint8Array>({
pull() {
throw new Error("stream broke");
},
});
return new Response(body, { status: 200 });
}
return h.fetchFn(url, init);
},
@ -931,22 +935,18 @@ describe("useServers", () => {
fetchFn: async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (url.endsWith("/api/servers/events")) {
const body = {
getReader: () => ({
read: async () => {
reads += 1;
if (reads === 1) {
// Two frames in one chunk → one background refresh.
return {
done: false,
value: encoder.encode("event: change\n\n\n\n"),
};
}
return { done: true, value: undefined };
},
}),
};
return { ok: true, body } as unknown as Response;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
reads += 1;
if (reads === 1) {
// Two frames in one chunk → one background refresh.
controller.enqueue(encoder.encode("event: change\n\n\n\n"));
return;
}
controller.close();
},
});
return new Response(body, { status: 200 });
}
return h.fetchFn(url, init);
},
@ -1141,22 +1141,24 @@ describe("useServers", () => {
const fetchFn: typeof fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
if (url.endsWith("/api/servers/events")) {
const body = {
getReader: () => ({
read: async () => {
reads += 1;
// Priming comment only — no `event:` / `data:` line.
if (reads === 1) {
return { done: false, value: encoder.encode(":\n\n") };
}
// Hold the stream open so the loop can't end and let a
// teardown-time settle hide a queued refresh.
await secondRead;
return { done: true, value: undefined };
},
}),
};
return { ok: true, body } as unknown as Response;
// `pull` runs once per read, so the counting and the blocking
// second read work the same way they would on a hand-rolled reader
// double — while staying type-checked against the Response API.
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
reads += 1;
// Priming comment only — no `event:` / `data:` line.
if (reads === 1) {
controller.enqueue(encoder.encode(":\n\n"));
return;
}
// Hold the stream open so the loop can't end and let a
// teardown-time settle hide a queued refresh.
await secondRead;
controller.close();
},
});
return new Response(body, { status: 200 });
}
if (url.endsWith("/api/servers")) listGets += 1;
return h.fetchFn(url, init);

View file

@ -0,0 +1,514 @@
/**
* `withSecretFileLock` against a real filesystem and a real second process
* (#2082).
*
* The property under test is cross-process mutual exclusion, and the
* existing suite structurally cannot reach it: `FileSecretStore.serialize`
* is one process-wide queue per path, so two in-process callers are ordered
* before the lock ever sees them. A child process is not scaffolding here —
* it is the only participant that can produce the interleaving.
*/
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
type MockInstance,
} from "vitest";
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { promisify } from "node:util";
import {
withSecretFileLock,
resetFileLockWarnings,
} from "@inspector/core/auth/node/file-lock.js";
import { FileSecretStore } from "@inspector/core/auth/node/file-secret-store.js";
import { SecretStoreUnavailableError } from "@inspector/core/auth/node/secret-store.js";
const run = promisify(execFile);
const require_ = createRequire(import.meta.url);
/**
* Resolved in the parent and handed to the child. The child's cwd is not
* this repo, and `proper-lockfile` lives in the *root* install rather than
* `clients/web`'s, so a bare `require("proper-lockfile")` there resolves
* against whatever happens to be above the temp directory — usually nothing.
*/
const LOCKFILE_MODULE = require_.resolve("proper-lockfile");
let tmpDir: string;
let warn: MockInstance<typeof console.warn>;
const filePath = (): string => path.join(tmpDir, "secrets.json");
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "inspector-lock-"));
resetFileLockWarnings();
// These paths warn by design; asserting on the text is the point, and
// letting it reach the real console would bury the suite's output.
warn = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(async () => {
warn.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
});
/** Everything `console.warn` was handed this test, as one string. */
const warnings = (): string =>
warn.mock.calls.map((c) => String(c[0])).join("\n");
/**
* Hold the lock on `target` in a **real second process** for `holdMs`, and
* resolve once that child confirms it has it.
*
* Resolving on the child's confirmation rather than on a sleep is what makes
* the ordering assertions below meaningful: the parent starts contending
* only once the lock is provably held elsewhere, so a pass cannot come from
* the parent simply getting there first.
*/
async function holdLockInChildProcess(
target: string,
holdMs: number,
/**
* Secrets the child writes *while holding the lock*, before it announces
* itself. A parent that honours the lock therefore reads a map that already
* contains them, which is what lets the caller assert on the merged result
* rather than only on timing.
*/
writeWhileHeld?: Record<string, string>,
): Promise<{ ready: Promise<void>; done: Promise<void> }> {
const script = `
const lockfile = require(${JSON.stringify(LOCKFILE_MODULE)});
const fs = require("node:fs");
const path = require("node:path");
// A second Inspector reaches the lock through \`withSecretFileLock\`, which
// creates the storage directory first. Mirror that, or the fresh-install
// case below would be testing the child's omission rather than the parent.
fs.mkdirSync(path.dirname(${JSON.stringify(target)}), { recursive: true });
lockfile
.lock(${JSON.stringify(target)}, { realpath: false, stale: 10000 })
.then(async (release) => {
const secrets = ${JSON.stringify(writeWhileHeld ?? null)};
if (secrets) {
fs.writeFileSync(
${JSON.stringify(target)},
JSON.stringify({ version: 1, encryption: "none", secrets }),
);
}
process.stdout.write("acquired\\n");
await new Promise((r) => setTimeout(r, ${holdMs}));
await release();
process.stdout.write("released\\n");
})
.catch((err) => {
process.stdout.write("failed:" + err.code + "\\n");
process.exitCode = 1;
});
`;
const child = run(process.execPath, ["-e", script]);
let seenReady = false;
const done = child.then(({ stdout }) => {
expect(stdout).toContain("acquired");
expect(stdout).toContain("released");
});
// `execFile` buffers, so the "acquired" line is only readable off the
// stream. Subscribe before awaiting anything, or the line is missed.
const ready = new Promise<void>((resolve, reject) => {
child.child.stdout?.on("data", (chunk: Buffer) => {
if (!seenReady && chunk.toString().includes("acquired")) {
seenReady = true;
resolve();
}
});
child.catch(reject);
});
return { ready, done };
}
describe("withSecretFileLock across processes", () => {
it("waits for a lock another process holds, then runs", async () => {
const target = filePath();
const { ready, done } = await holdLockInChildProcess(target, 400);
await ready;
const startedAt = Date.now();
let ranAt = 0;
await withSecretFileLock(target, async () => {
ranAt = Date.now();
});
// It waited rather than barging in. The child holds for 400ms and the
// retry schedule's first sleeps are tens of milliseconds, so anything
// above a floor well under 400 proves contention without pinning the
// assertion to the scheduler's exact wake-up.
expect(ranAt - startedAt).toBeGreaterThan(200);
// …and having waited, it did not report a degraded write.
expect(warnings()).toBe("");
await done;
}, 20_000);
it("locks a file that does not exist yet", async () => {
// The first `set` on a fresh install has no `secrets.json` — and
// `proper-lockfile` resolves its target through `fs.realpath` by
// default, which is `ENOENT` there. `realpath: false` is what makes the
// very first write lockable; without it the one call with nothing to
// fall back on is the one that runs unprotected.
const target = filePath();
await expect(fs.stat(target)).rejects.toThrow();
let ran = false;
await withSecretFileLock(target, async () => {
ran = true;
});
expect(ran).toBe(true);
expect(warnings()).toBe("");
});
it("makes FileSecretStore.set wait on a lock another process holds", async () => {
// The end-to-end shape from the issue: a CLI run beside a web session.
//
// Deliberately asserts that the parent's `set` has *not finished* while
// the child holds the lock. A test that only checks both keys survive
// afterwards passes with the lock removed from `mutate` entirely — the
// optimistic verify would repair the clobber and hide the regression.
// Not-yet-resolved is the observation only a real lock can produce.
const target = filePath();
const { ready, done } = await holdLockInChildProcess(target, 700, {
"srv:env:FROM_CHILD": "1",
});
await ready;
let settled = false;
const store = new FileSecretStore({ filePath: target });
const pending = store.set("srv", "env:FROM_PARENT", "2").then(() => {
settled = true;
});
// Comfortably inside the child's hold, and comfortably outside the few
// milliseconds an unlocked read-modify-write would take.
await new Promise((r) => setTimeout(r, 300));
expect(settled).toBe(false);
await done;
await pending;
// Having waited, the parent read the map the child left, so its own entry
// landed *on top of* the child's rather than replacing it.
const reader = new FileSecretStore({ filePath: target });
expect(await reader.get("srv", "env:FROM_CHILD")).toBe("1");
expect(await reader.get("srv", "env:FROM_PARENT")).toBe("2");
expect(warnings()).toBe("");
}, 20_000);
it("creates the storage directory so the very first save is locked too", async () => {
// `writeStoreFile` creates the parent directory, but from *inside* the
// locked section — so without the `mkdir` in `withSecretFileLock` the
// first save on a fresh install fails `ENOENT` on the lock and degrades
// to an unlocked write. That is the save most likely to be racing
// another, since two Inspectors started together both reach it.
const target = path.join(tmpDir, "fresh-install", "secrets.json");
const { ready, done } = await holdLockInChildProcess(target, 700);
await ready;
let settled = false;
const store = new FileSecretStore({ filePath: target });
const pending = store.set("srv", "env:FIRST_EVER", "1").then(() => {
settled = true;
});
await new Promise((r) => setTimeout(r, 300));
expect(settled).toBe(false);
await done;
await pending;
const reader = new FileSecretStore({ filePath: target });
expect(await reader.get("srv", "env:FIRST_EVER")).toBe("1");
// No degrade warning: the lock was genuinely held, not skipped.
expect(warnings()).toBe("");
}, 20_000);
});
describe("withSecretFileLock degrades rather than failing", () => {
it("runs the body anyway when the lock cannot be created, and says so once", async () => {
// A path whose parent is a *file* stands in for every real variant —
// read-only `$HOME`, a mount owned by another uid, a filesystem without
// `mkdir` semantics — and unlike a permissions-based setup it fails the
// same way for root, so it cannot pass locally and flake in a container.
// Note a merely *missing* directory is no longer this case:
// `withSecretFileLock` creates it. This store exists for boxes where the
// usual mechanism is missing, so it must not gain a new way to be
// unavailable.
await fs.writeFile(path.join(tmpDir, "not-a-dir"), "", "utf-8");
const target = path.join(tmpDir, "not-a-dir", "secrets.json");
let ran = 0;
await withSecretFileLock(target, async () => {
ran += 1;
});
await withSecretFileLock(target, async () => {
ran += 1;
});
expect(ran).toBe(2);
expect(warnings()).toContain("Could not take a lock on the secrets file");
// Once per reason per process — a warning on every save would be noise
// on precisely the deployment that cannot act on it.
expect(warn).toHaveBeenCalledTimes(1);
});
it("refuses the save rather than writing alongside a live holder", async () => {
// `ELOCKED` is evidence the lock is *working*, so degrading here would
// enter the exact interleaving the lock exists to prevent — and enter it
// knowing another writer is there. Held from this process, which is
// indistinguishable to `proper-lockfile` from a remote holder (it is not
// reentrant); the in-process queue is what keeps that out of the way in
// production.
//
// The wait is real: the retry budget deliberately outlasts the 10s stale
// window so a *crashed* holder resolves by takeover instead of failing
// everyone else's saves. This holder is alive and refreshing, so it never
// goes stale and the budget is spent in full.
const target = filePath();
const lockfile = require_(LOCKFILE_MODULE) as {
lock: (f: string, o: object) => Promise<() => Promise<void>>;
};
// The **same** `stale` production uses, and that is not incidental:
// `isLockStale` is evaluated against the *waiter's* threshold while the
// holder refreshes on its own `stale / 2`. A holder configured looser
// (say 60s) refreshes every 30s and is therefore declared stale by a
// 10s waiter after 10s — the waiter takes over and the save succeeds,
// quietly testing the opposite of what this test claims.
const release = await lockfile.lock(target, {
realpath: false,
stale: 10_000,
});
const store = new FileSecretStore({ filePath: target });
// One call, both assertions off the same rejection: each attempt spends
// the full retry budget, so a second would double the test's runtime to
// re-prove the same thing.
const err = await store
.set("srv", "env:MINE", "1")
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(SecretStoreUnavailableError);
expect((err as Error).message).toMatch(/was not saved/);
await release();
// Nothing was written behind the holder's back.
expect(existsSync(target)).toBe(false);
}, 90_000);
it("refuses rather than degrading when a stale lock cannot be cleared", async () => {
// `acquireLock` does not only *create* directories — on finding a stale
// one it removes it and retries, and that removal can fail. A stale lock
// with anything inside it fails `ENOTEMPTY`, which is not `ELOCKED`, and
// treating every non-`ELOCKED` error as "locks do not work here" meant
// every Inspector on the box quietly bypassed the *same* stuck lock and
// raced its writes — while the release-failure message was telling the
// operator saves would keep failing until they cleared it.
const target = filePath();
const lockPath = `${target}.lock`;
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.mkdir(lockPath);
await fs.writeFile(`${lockPath}/stray`, "", "utf-8");
// Backdated so it reads as stale — which is what sends `acquireLock` down
// the remove-and-retry path rather than straight to `ELOCKED`.
const longDead = new Date(Date.now() - 60_000);
await fs.utimes(lockPath, longDead, longDead);
const store = new FileSecretStore({ filePath: target });
const err = await store
.set("srv", "env:MINE", "1")
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(SecretStoreUnavailableError);
expect((err as Error).message).toMatch(/was not saved/);
// Nothing written behind the stuck lock, and no "unprotected" warning:
// this is a refusal, not a degrade.
expect(existsSync(target)).toBe(false);
expect(warnings()).not.toContain("not protected");
}, 90_000);
it("takes over the lock of a holder that died, rather than failing the save", async () => {
// The invariant behind refusing on `ELOCKED`: refusing is only defensible
// because a *crashed* holder resolves on its own first. `RETRY` therefore
// has to outlast `STALE_MS` — if the budget were the shorter of the two,
// one Inspector killed mid-save would make every later save on the box
// fail until someone deleted the lock by hand.
//
// A dead holder is exactly a lock directory nobody is refreshing, so it
// is staged directly: no child to race, and no dependence on how quickly
// a killed process is reaped.
const target = filePath();
const lockPath = `${target}.lock`;
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.mkdir(lockPath);
const longDead = new Date(Date.now() - 60_000);
await fs.utimes(lockPath, longDead, longDead);
const store = new FileSecretStore({ filePath: target });
await store.set("srv", "env:AFTER_CRASH", "1");
const reader = new FileSecretStore({ filePath: target });
expect(await reader.get("srv", "env:AFTER_CRASH")).toBe("1");
// Took the lock over — did not fall through to an unlocked write.
expect(warnings()).toBe("");
}, 60_000);
it("stays silent per the delete contract when the lock is held", async () => {
// `delete` reports nothing by contract — only `set` hard-fails — so the
// refusal above must not turn a delete into a throw.
const target = filePath();
const store = new FileSecretStore({ filePath: target });
await store.set("srv", "env:A", "1");
const lockfile = require_(LOCKFILE_MODULE) as {
lock: (f: string, o: object) => Promise<() => Promise<void>>;
};
// The **same** `stale` production uses, and that is not incidental:
// `isLockStale` is evaluated against the *waiter's* threshold while the
// holder refreshes on its own `stale / 2`. A holder configured looser
// (say 60s) refreshes every 30s and is therefore declared stale by a
// 10s waiter after 10s — the waiter takes over and the save succeeds,
// quietly testing the opposite of what this test claims.
const release = await lockfile.lock(target, {
realpath: false,
stale: 10_000,
});
await expect(store.delete("srv", "env:A")).resolves.toBeUndefined();
await release();
// …and the entry it could not delete is still there, not half-removed.
expect(await store.get("srv", "env:A")).toBe("1");
}, 90_000);
});
describe("withSecretFileLock reports what it cannot clean up", () => {
it("does not delete the winner's lock after being taken over", async () => {
// The destructive half of the stale-takeover race. `proper-lockfile`'s
// removal is an unconditional `rmdir`, so a holder whose lock was
// replaced deletes the *winner's* directory on the way out — one
// compromised holder becoming two unprotected writers. Its own detection
// cannot prevent that: it runs on the refresh tick (5s here) while an
// ordinary mutation finishes in well under a second.
//
// No waiting here, deliberately: this is the fast case the tick misses.
const target = filePath();
const lockPath = `${target}.lock`;
let winner: { ino: number; birthtimeMs: number } | undefined;
const result = await withSecretFileLock(target, async () => {
await fs.rm(lockPath, { recursive: true, force: true });
await fs.mkdir(lockPath);
const stat = await fs.stat(lockPath);
winner = { ino: stat.ino, birthtimeMs: stat.birthtimeMs };
return "saved";
});
expect(result).toBe("saved");
// Same directory, not a recreated one — it was left alone, not deleted.
const after = await fs.stat(lockPath);
expect(after.ino).toBe(winner?.ino);
expect(after.birthtimeMs).toBe(winner?.birthtimeMs);
expect(warnings()).toContain("was taken over by another process");
});
it("the exit handler this guards against really does delete a lock", async () => {
// The other lifecycle window, and the reason the guard lives in
// `options.fs` rather than in a check before `release()`:
// `proper-lockfile` registers a `signal-exit` handler that `rmdirSync`s
// every lock it still has registered, with no ownership check of its own.
// A guard placed only around release would leave that free to delete the
// winner's directory if the process exits at the wrong moment.
//
// Shown in a real child, because the handler only runs on a real exit,
// and with an **empty** replacement directory — a non-empty one makes
// `rmdirSync` fail `ENOTEMPTY` and would "pass" for the wrong reason,
// which is exactly how the first draft of this test fooled itself.
//
// `withSecretFileLock` routes that same handler through the shim's
// `rmdirSync`, which shares `removeIfMine` with the release path proven
// by the test above.
const target = filePath();
const lockPath = `${target}.lock`;
await fs.mkdir(path.dirname(target), { recursive: true });
const script = `
const lockfile = require(${JSON.stringify(LOCKFILE_MODULE)});
const fs = require("node:fs");
const lockPath = ${JSON.stringify(lockPath)};
lockfile
.lock(${JSON.stringify(target)}, { realpath: false, stale: 10000 })
.then(() => {
// Taken over while we hold it, then exit *without* releasing.
fs.rmSync(lockPath, { recursive: true, force: true });
fs.mkdirSync(lockPath);
process.exit(0);
});
`;
await run(process.execPath, ["-e", script]);
// Unguarded, the winner's directory is gone.
expect(existsSync(lockPath)).toBe(false);
}, 30_000);
it("tells the operator to clear a lock that cannot expire on its own", async () => {
// `rmdir` refuses a non-empty directory — and so does stale takeover,
// which reclaims through the same call. So an `ENOTEMPTY` lock is not
// cleaned up by the staleness mechanism, by us, or by the next writer:
// every later save fails `ELOCKED` against it until somebody deletes it.
// Promising it "expires on its own after 10s" would be false.
const target = filePath();
const result = await withSecretFileLock(target, async () => {
await fs.writeFile(`${target}.lock/stray`, "", "utf-8");
return "saved";
});
// The body's result is returned regardless: a save that completed must
// not be turned into a failure by its own teardown.
expect(result).toBe("saved");
expect(warnings()).toContain("Could not release the lock");
// Conditional guidance, not an instruction. `proper-lockfile` forwards
// whatever the filesystem returned and nothing here can tell a permanent
// refusal from a transient one — and if it was transient, this path may
// by then hold a *different, live* Inspector's lock, so an unconditional
// "remove it" would destroy the exclusion of a process that did nothing
// wrong. Both conditions must be stated.
expect(warnings()).toContain("If saves keep failing");
expect(warnings()).toContain("no other Inspector is running");
// …and it must not promise the lock clears by itself either, which is
// false whenever the same `rmdir` also blocks stale takeover.
expect(warnings()).not.toMatch(/It expires on its own/);
});
it("warns rather than crashing the process when the library detects the takeover", async () => {
// `proper-lockfile`'s default `onCompromised` *throws*, from a timer with
// no caller on the stack — an uncaught exception that takes an Inspector
// session down. Replaced with a warning. This is the slow path: the lock
// is removed and the body stays alive past the refresh tick, so the
// library's own detection fires rather than the removal guard.
const target = filePath();
await withSecretFileLock(target, async () => {
await fs.rm(`${target}.lock`, { recursive: true, force: true });
await vi.waitFor(
() => expect(warnings()).toContain("was taken over by another process"),
{ timeout: 20_000, interval: 250 },
);
});
// And it must NOT go on to tell the operator to delete the lock. Once the
// tick has fired, `release()` answers `ERELEASED` without touching the
// filesystem — the directory sitting at that path is then whoever took
// over, so "remove it by hand" would destroy the exclusion of a process
// that did nothing wrong. `onCompromised` has already said what happened.
expect(warnings()).not.toContain("by hand");
expect(warnings()).not.toContain("Could not release the lock");
}, 30_000);
});

View file

@ -684,6 +684,197 @@ describe("absorbFileSecretsIntoKeyring", () => {
return filePath;
}
it("does not throw when another process holds the lock (#2082)", async () => {
// `withSecretFileLock` throws on a lock held past its retry budget, which
// is right for a `set` — the user is waiting on that value — and wrong
// here. This function is awaited directly by both `resolveSecretStore`
// branches, so an escaping error fails store resolution and with it the
// whole session: a stuck writer elsewhere on the box would stop the
// Inspector from starting. Leaving the file for the next run is the only
// acceptable outcome.
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(true);
const properLockfile = (await import("proper-lockfile")).default;
const release = await properLockfile.lock(filePath, {
realpath: false,
stale: 10_000,
});
await expect(
mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore()),
).resolves.toBeUndefined();
await release();
// Left exactly as it was, and said so — asserting the *lock* message
// specifically, since the pre-existing claim-failure warning also ends in
// "left in place" and would let this pass without the lock ever being hit.
expect(existsSync(filePath)).toBe(true);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Could not lock the secrets file"),
);
}, 60_000);
it("takes no lock when there is nothing to migrate (#2082)", async () => {
// The overwhelmingly common startup: a keychain is available and no file
// was ever written. Locking first would create and remove a lock
// directory on every run — and on a box whose storage directory does not
// exist yet the lock cannot be created at all, so the degrade path would
// warn about unprotected writes on every single run, with nothing to
// protect.
const filePath = path.join(tmpDir, "no-such-dir", "secrets.json");
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(true);
await mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore());
expect(warn).not.toHaveBeenCalled();
expect(existsSync(`${filePath}.lock`)).toBe(false);
});
it("treats ENOTDIR as the fresh-install case, like ENOENT (#2082)", async () => {
// A path whose parent is a *file* answers `readdir` with ENOTDIR — the
// other shape of "there is no directory here" — so it takes the same
// silent path as a missing one. The *unlistable* case is different and is
// covered by the test below.
await fs.writeFile(path.join(tmpDir, "not-a-dir"), "", "utf-8");
const filePath = path.join(tmpDir, "not-a-dir", "secrets.json");
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(true);
await mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore());
// ENOTDIR is a fresh-install shape: nothing said, nothing locked.
expect(warn).not.toHaveBeenCalled();
});
// Root bypasses POSIX permission checks, and Windows does not model them
// the same way — in either case the directory below stays listable and the
// test would assert nothing.
const canDenyListing =
process.platform !== "win32" && process.getuid?.() !== 0;
it.skipIf(!canDenyListing)(
"does not treat an unlistable directory as an empty one (#2082)",
async () => {
// The case ENOTDIR does *not* cover, and the reason the catch narrowed
// to ENOENT/ENOTDIR rather than swallowing everything. A directory with
// `--x` permission denies `readdir` with EACCES while still permitting
// access to a *known* name inside it — so reading EACCES as "nothing to
// migrate" selects the keychain and leaves those secrets invisible with
// nothing said. That asymmetry is real POSIX behaviour, which is why
// this drives it with a real mode rather than a stubbed `readdir`.
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
vi.spyOn(console, "warn").mockImplementation(() => {});
// Write + execute, no read: `readdir` fails, `stat`/`open`/`rename` of a
// known name still work — which is exactly what the migration needs.
await fs.chmod(tmpDir, 0o300);
try {
const mod = await loadWithProbe(true);
const keyring = new InMemorySecretStore();
await mod.absorbFileSecretsIntoKeyring(keyring);
// It went on and migrated, rather than silently skipping.
expect(await keyring.get("srv", "oauthClientSecret")).toBe("from-file");
} finally {
// Restore before `afterEach`, which needs to list it to remove it.
await fs.chmod(tmpDir, 0o700);
}
},
);
it("does not adopt a snapshot another migration is still using (#2082)", async () => {
// A `*.migrating-*` file is not automatically an orphan: the process that
// staged it may still be reading it. Adopting one mid-hand-off links it
// back to the live path and re-claims it under a new name, so its owner's
// hand-off fails ENOENT — and if we exit before copying, that healthy
// session starts with none of those secrets.
//
// Liveness is the snapshot's own lock rather than the pid in its name: a
// pid outlives its process and recurs (pid 1 on every container start),
// whereas the lock expires by itself if the owner dies.
const inProgress = path.join(tmpDir, "secrets.json.migrating-999-abc");
await fs.writeFile(
inProgress,
JSON.stringify({
version: 1,
encryption: "none",
secrets: { "srv:oauthClientSecret": "still-migrating" },
}),
"utf-8",
);
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json");
vi.spyOn(console, "warn").mockImplementation(() => {});
const properLockfile = (await import("proper-lockfile")).default;
const release = await properLockfile.lock(inProgress, {
realpath: false,
stale: 10_000,
});
const mod = await loadWithProbe(true);
const keyring = new InMemorySecretStore();
await mod.absorbFileSecretsIntoKeyring(keyring);
await release();
// Left exactly where its owner put it, and not migrated from under it.
expect(existsSync(inProgress)).toBe(true);
expect(existsSync(path.join(tmpDir, "secrets.json"))).toBe(false);
expect(await keyring.get("srv", "oauthClientSecret")).toBe(null);
});
it("ignores a snapshot's own lock directory (#2082)", async () => {
// `secrets.json.migrating-<pid>-<uuid>.lock` matches the plain prefix
// test, and treating it as a snapshot is self-sustaining damage: the
// liveness probe asks about a nonexistent `<name>.lock.lock` and answers
// "not held", recovery tries to hard-link a *directory* onto the secrets
// path, fails, and prints the orphan warning — every startup, forever,
// since a liveness *check* never clears a stale lock directory.
const strayLock = path.join(tmpDir, "secrets.json.migrating-999-abc.lock");
await fs.mkdir(strayLock);
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(true);
await mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore());
// Not mistaken for a snapshot: nothing said, nothing linked, and the
// directory left exactly where it was.
expect(warn).not.toHaveBeenCalled();
expect(existsSync(strayLock)).toBe(true);
expect(existsSync(path.join(tmpDir, "secrets.json"))).toBe(false);
});
it("still adopts an orphan when the live file is absent (#2082)", async () => {
// The fast path above must not be a `stat` of `secrets.json`: an
// interrupted migration leaves *only* the snapshot, which is precisely
// the case where there is everything to migrate and no live file.
const orphan = path.join(tmpDir, "secrets.json.migrating-123-abc");
await fs.writeFile(
orphan,
JSON.stringify({
version: 1,
encryption: "none",
secrets: { "srv:oauthClientSecret": "from-orphan" },
}),
"utf-8",
);
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json");
vi.spyOn(console, "warn").mockImplementation(() => {});
const mod = await loadWithProbe(true);
const keyring = new InMemorySecretStore();
await mod.absorbFileSecretsIntoKeyring(keyring);
expect(await keyring.get("srv", "oauthClientSecret")).toBe("from-orphan");
expect(existsSync(orphan)).toBe(false);
});
it("moves the file's secrets into the keychain and removes the file", async () => {
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;

View file

@ -21,8 +21,14 @@ describe("getViteBaseConfig", () => {
expect.arrayContaining([
"@modelcontextprotocol/client/stdio",
"atomically",
"chokidar",
"cross-spawn",
"which",
"@napi-rs/keyring",
// #2082 — reached through `core/auth/node/file-lock.ts`. The tsup
// `external` lists cover the production bundles, not `vite dev`, so
// a node-only dependency has to be named in both places.
"proper-lockfile",
]),
);
});

View file

@ -27,6 +27,13 @@ export default defineConfig({
"atomically",
"chokidar",
"@napi-rs/keyring",
// Root-declared (see the repo's dependency-placement rule) and CJS, which
// is the combination that bites: tsup externalizes what the *client's*
// package.json declares, so a root-only dependency is bundled unless named
// here — and inlining a CJS module into an ESM bundle leaves esbuild's
// `Dynamic require of "path" is not supported` shim, which throws at
// import time and takes the whole binary down before it parses a flag.
"proper-lockfile",
"@modelcontextprotocol/client",
"@modelcontextprotocol/core",
],