* fix(core): trace dropped fetch body update when entry rotates out (#1390) FetchRequestLogState.onFetchRequestBodyUpdate silently dropped a deferred response-body update when the matching entry had already been evicted by log rotation (>= maxFetchRequests newer requests appended between the append and the fire-and-forget body read). The user saw an empty Response Body with no way to distinguish "body never came back" from "body dropped after eviction". Plumb an optional pino logger into FetchRequestLogState (defaulting to the silent logger) and emit a logger.debug at the early-return site so the drop is traceable. The web client wires the existing remote logger from createWebEnvironment into the state manager. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): emit dropped-body trace at warn level so it is observable (#1390) Review feedback: the web remote logger (createRemoteLogger) defaults to level 'info' with no override at the environmentFactory call site, so a logger.debug at the drop site is filtered out by both the pino level gate and transmit.level — the diagnostic never reaches /api/log and the drop stays invisible in the shipped web app, defeating the issue's goal. Promote the trace to logger.warn (>= info, so it transmits by default). A response body lost to log rotation is unexpected data loss, not routine diagnostic noise, so warn is the appropriate severity. Update the comment to explain the level choice and switch the test stub from debug to warn, extracting a small typed makeWarnLogger helper per review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): configurable Network Log Size per server + body-dropped toast (#1390) Turn the otherwise-invisible "response body dropped after rotation" case into an actionable signal: 1. Per-server `maxFetchRequests` setting (default 1000, 0 = unlimited), editable as "Network Log Size" in the first (Options) accordion section of the Server Settings form. Plumbed through the full settings pipeline: InspectorServerSettings + StoredMCPServer types, the serverList disk converters (read defaults to 1000, write omits the default to keep diffs minimal — 0 is a meaningful non-default), and the node server's GET-sanitize + write-side validateSettings (non-negative number). Applied live to the active connection on settings-modal close via a new FetchRequestLogState.setMaxFetchRequests (shrinking trims immediately), and at connect time for the next session. 2. FetchRequestLogState now emits a `fetchRequestBodyDropped` event, but only for genuine rotation drops (log at capacity with a finite cap) — not for benign post-clear stragglers or unlimited logs, which previously also hit the warn path. App shows a deduped, persistent toast (stable per-server id, no auto-close) explaining what happened and the likely causes, with a link that opens this server's settings on the Options section (modal remounts via key) so the user can raise the limit if it's just a high-traffic server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): don't treat a cleared Network Log Size field as unlimited (#1390) Review feedback: clearing the NumberInput emits "", and `parseInt("") || 0` coerced that to 0 (unlimited). Since the value applies live on modal close, a user who cleared the field to retype and then closed the modal would silently switch the log to unlimited. Fall back to the current value on an empty/NaN parse and reserve 0 for an explicit numeric entry. Adds a cleared-input test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef921ba052
commit
53dc4549be
8 changed files with 717 additions and 13 deletions
|
|
@ -342,6 +342,7 @@ describe("RemoteClientTransport", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
};
|
||||
const transport = new RemoteClientTransport(
|
||||
|
|
|
|||
|
|
@ -247,6 +247,58 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("round-trips maxFetchRequests: lifts a non-default value to settings and back to disk", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
zeta: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
maxFetchRequests: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(original);
|
||||
expect(entry?.settings?.maxFetchRequests).toBe(5000);
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("round-trips maxFetchRequests: 0 (unlimited) — a meaningful non-default value", () => {
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
eta: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
maxFetchRequests: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(original);
|
||||
expect(entry?.settings?.maxFetchRequests).toBe(0);
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("omits maxFetchRequests from disk when it equals the default", () => {
|
||||
// Absent on disk lifts to the product default (1000) in memory; writing it
|
||||
// back must NOT inject the field, keeping the diff minimal. A benign
|
||||
// inspector field (connectionTimeout) is present so `settings` is built.
|
||||
const original: MCPConfig = {
|
||||
mcpServers: {
|
||||
theta: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
connectionTimeout: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
const [entry] = mcpConfigToServerEntries(original);
|
||||
expect(entry?.settings?.maxFetchRequests).toBe(1000);
|
||||
const round = serverEntriesToMcpConfig(mcpConfigToServerEntries(original));
|
||||
expect("maxFetchRequests" in (round.mcpServers.theta ?? {})).toBe(false);
|
||||
expect(round).toEqual(original);
|
||||
});
|
||||
|
||||
it("lifts top-level Inspector-extension fields onto ServerEntry.settings (form shape)", () => {
|
||||
const cfg: MCPConfig = {
|
||||
mcpServers: {
|
||||
|
|
@ -269,6 +321,8 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
taskTtl: 60000,
|
||||
// Absent autoRefreshOnListChanged on disk → false in memory (for the form)
|
||||
autoRefreshOnListChanged: false,
|
||||
// Absent maxFetchRequests on disk → product default in memory (for the form)
|
||||
maxFetchRequests: 1000,
|
||||
// Absent roots on disk → empty list in memory (for the form)
|
||||
roots: [],
|
||||
// Nested oauth on disk → flat oauthClientId in memory
|
||||
|
|
@ -343,6 +397,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
|
|
@ -368,6 +423,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
|
|
@ -395,6 +451,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [
|
||||
{ uri: "file:///project", name: "Project" },
|
||||
{ uri: "file:///tmp" },
|
||||
|
|
@ -432,6 +489,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [
|
||||
{ uri: "file:///keep", name: " " },
|
||||
{ uri: " " },
|
||||
|
|
@ -457,6 +515,7 @@ describe("serverEntriesToMcpConfig", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [{ uri: "" }],
|
||||
},
|
||||
connection: { status: "disconnected" },
|
||||
|
|
|
|||
|
|
@ -44,6 +44,32 @@ describe("FetchRequestLogState", () => {
|
|||
let client: FakeInspectorClient;
|
||||
let state: FetchRequestLogState;
|
||||
|
||||
type LoggerOption = NonNullable<
|
||||
ConstructorParameters<typeof FetchRequestLogState>[1]
|
||||
>["logger"];
|
||||
|
||||
// Stub logger exposing just the `warn` method exercised on the drop path,
|
||||
// cast to the FetchRequestLogStateOptions["logger"] type. Returns both the
|
||||
// logger and the spy so a test can assert the diagnostic was emitted.
|
||||
function makeWarnLogger(): {
|
||||
logger: LoggerOption;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const warn = vi.fn();
|
||||
return { logger: { warn } as unknown as LoggerOption, warn };
|
||||
}
|
||||
|
||||
// Build a state wired to a stub logger so the diagnostic on the rotated-out
|
||||
// drop path can be asserted.
|
||||
function makeLoggedState(
|
||||
logger: LoggerOption,
|
||||
extra: Partial<
|
||||
NonNullable<ConstructorParameters<typeof FetchRequestLogState>[1]>
|
||||
> = {},
|
||||
): FetchRequestLogState {
|
||||
return new FetchRequestLogState(client, { logger, ...extra });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
client = new FakeInspectorClient();
|
||||
state = new FetchRequestLogState(client);
|
||||
|
|
@ -120,15 +146,92 @@ describe("FetchRequestLogState", () => {
|
|||
expect(seen).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores fetchRequestBodyUpdate for unknown ids", () => {
|
||||
it("silently ignores a body update for an unknown id when the log is not full (benign straggler)", () => {
|
||||
// Below capacity, an idx === -1 means the entry was cleared or never
|
||||
// existed — not a rotation drop — so it must NOT warn or emit the dropped
|
||||
// event (which would mislead the user into raising the log size).
|
||||
const { logger, warn } = makeWarnLogger();
|
||||
const logged = makeLoggedState(logger, { maxFetchRequests: 1000 });
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
let changes = 0;
|
||||
state.addEventListener("fetchRequestsChange", () => changes++);
|
||||
let dropped = 0;
|
||||
logged.addEventListener("fetchRequestsChange", () => changes++);
|
||||
logged.addEventListener("fetchRequestBodyDropped", () => dropped++);
|
||||
client.dispatchTypedEvent("fetchRequestBodyUpdate", {
|
||||
id: "nonexistent",
|
||||
responseBody: "x",
|
||||
});
|
||||
expect(changes).toBe(0);
|
||||
expect(dropped).toBe(0);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("traces and emits when the entry rotated out before the body arrived", () => {
|
||||
const { logger, warn } = makeWarnLogger();
|
||||
const logged = makeLoggedState(logger, { maxFetchRequests: 1 });
|
||||
const dropped: { id: string; maxFetchRequests: number }[] = [];
|
||||
logged.addEventListener("fetchRequestBodyDropped", (e) =>
|
||||
dropped.push(e.detail),
|
||||
);
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
// A newer request evicts "a" before its deferred body update arrives.
|
||||
client.dispatchTypedEvent("fetchRequest", entry("b"));
|
||||
client.dispatchTypedEvent("fetchRequestBodyUpdate", {
|
||||
id: "a",
|
||||
responseBody: "late",
|
||||
});
|
||||
expect(logged.getFetchRequests().map((e) => e.id)).toEqual(["b"]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fetchRequestId: "a",
|
||||
maxFetchRequests: 1,
|
||||
}),
|
||||
expect.stringContaining("rotated out"),
|
||||
);
|
||||
expect(dropped).toEqual([{ id: "a", maxFetchRequests: 1 }]);
|
||||
});
|
||||
|
||||
it("does not flag a rotation drop when the log is unlimited (maxFetchRequests = 0)", () => {
|
||||
// With no cap, entries never rotate out, so an unknown id is always a
|
||||
// benign straggler — never a data-loss event.
|
||||
const { logger, warn } = makeWarnLogger();
|
||||
const logged = makeLoggedState(logger, { maxFetchRequests: 0 });
|
||||
let dropped = 0;
|
||||
logged.addEventListener("fetchRequestBodyDropped", () => dropped++);
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
client.dispatchTypedEvent("fetchRequestBodyUpdate", {
|
||||
id: "gone",
|
||||
responseBody: "x",
|
||||
});
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(dropped).toBe(0);
|
||||
});
|
||||
|
||||
it("setMaxFetchRequests trims live when shrunk and re-emits", () => {
|
||||
const sized = new FetchRequestLogState(client, { maxFetchRequests: 5 });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
client.dispatchTypedEvent("fetchRequest", entry(`e${i}`));
|
||||
}
|
||||
const seen: string[][] = [];
|
||||
sized.addEventListener("fetchRequestsChange", (e) =>
|
||||
seen.push(e.detail.map((x) => x.id)),
|
||||
);
|
||||
sized.setMaxFetchRequests(2);
|
||||
// Trims to the newest 2 and emits once.
|
||||
expect(sized.getFetchRequests().map((e) => e.id)).toEqual(["e3", "e4"]);
|
||||
expect(seen).toEqual([["e3", "e4"]]);
|
||||
});
|
||||
|
||||
it("setMaxFetchRequests is a no-op when unchanged or when growing within bounds", () => {
|
||||
const sized = new FetchRequestLogState(client, { maxFetchRequests: 2 });
|
||||
client.dispatchTypedEvent("fetchRequest", entry("a"));
|
||||
client.dispatchTypedEvent("fetchRequest", entry("b"));
|
||||
let changes = 0;
|
||||
sized.addEventListener("fetchRequestsChange", () => changes++);
|
||||
sized.setMaxFetchRequests(2); // unchanged
|
||||
sized.setMaxFetchRequests(10); // grown — nothing to trim
|
||||
expect(changes).toBe(0);
|
||||
expect(sized.getFetchRequests().map((e) => e.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("does NOT clear on connect or disconnect", () => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
autoRefreshOnListChanged: true,
|
||||
roots: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
autoRefreshOnListChanged: true,
|
||||
roots: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
autoRefreshOnListChanged: true,
|
||||
roots: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const AUTO_REFRESH_SETTINGS: InspectorServerSettings = {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
autoRefreshOnListChanged: true,
|
||||
roots: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -392,6 +392,7 @@ describe("useServers", () => {
|
|||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
taskTtl: 30000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
});
|
||||
});
|
||||
|
|
@ -403,6 +404,7 @@ describe("useServers", () => {
|
|||
connectionTimeout: 5000,
|
||||
requestTimeout: 30000,
|
||||
taskTtl: 30000,
|
||||
maxFetchRequests: 1000,
|
||||
autoRefreshOnListChanged: false,
|
||||
roots: [],
|
||||
});
|
||||
|
|
@ -436,6 +438,7 @@ describe("useServers", () => {
|
|||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 0,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
});
|
||||
}),
|
||||
|
|
@ -485,6 +488,7 @@ describe("useServers", () => {
|
|||
requestTimeout: 0,
|
||||
// Absent taskTtl on disk reads back as the product default for the form.
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
// Absent autoRefreshOnListChanged on disk reads back as false.
|
||||
autoRefreshOnListChanged: false,
|
||||
// Absent roots on disk reads back as an empty list for the form.
|
||||
|
|
|
|||
Loading…
Reference in a new issue