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:
commit
b1996d1093
2 changed files with 3185 additions and 230 deletions
225
clients/web/src/hooks/useLastPersistedSettings.test.tsx
Normal file
225
clients/web/src/hooks/useLastPersistedSettings.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
216
clients/web/src/hooks/useLastPersistedSettings.ts
Normal file
216
clients/web/src/hooks/useLastPersistedSettings.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue