mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 05:02:31 +00:00
fix: stop get_network_request waiting forever for an unread no-store body (#3216)
Fixes #3213. `get_network_request` reads the response body with Puppeteer's `HTTPResponse.buffer()`. That waits for the response to finish loading, with no timeout of its own, before it sends `Network.getResponseBody`. Chrome never finishes loading a `Cache-Control: no-store` response that the page doesn't read (e.g. `if (!res.ok) throw ...` on a 500), so the call never returns. It runs under the tool mutex, which blocks every later tool call on the server. I reproduced this locally with Puppeteer and headless Chrome. For a POST that returns 500 with `no-store` and that the page doesn't read, `buffer()` was still pending after 8 s at body sizes of 100 B, 100 KB and 5 MB. Without `no-store`, or when the page reads the body, it resolves in ~3 ms. The fix: `NetworkFormatter` now races the body read against `RESPONSE_BODY_TIMEOUT_MS` (5 s; tests can lower it with a new `responseBodyTimeout` option). On timeout, the response body shows as `<Response body not available: the response did not finish loading within 5000 ms>`, and the status, headers and the rest of the output are returned as usual. With `responseFilePath`, nothing is saved and the same message is shown instead of the generic "not available anymore". Responses that finish loading behave exactly as before. Tests: - `McpResponse`: a real page POSTs to a `no-store` 500 route and ignores the body. The attached request now returns its status and the timeout message instead of hanging. - `NetworkFormatter`: a response whose `buffer()` never settles gets the message both inline and with `responseFilePath`, and `saveFile` is not called. - `npm run build`, `eslint .` and `prettier --check` are clean. `McpResponse`, `NetworkFormatter`, `tools/network` and `McpContext` tests: 186/186 pass. One trade-off: a large body that takes longer than 5 s to finish downloading also gets the message now instead of a long wait. I kept the cap short because the tool mutex is held for the whole wait. I'm happy to change the value. _AI-assisted: I used an AI assistant to help write this change. I reviewed the code and tests and ran them locally._
This commit is contained in:
1 parent
f874aeb96a
commit
c556be173e
2 files changed
+114
-7
No files matched your search
@@ -14,6 +14,26 @@ import {
|
||||
|
||||
const BODY_CONTEXT_SIZE_LIMIT = 10000;
|
||||
const URL_CONTEXT_SIZE_LIMIT = 255;
|
||||
/**
|
||||
* How long to wait for a response body. Puppeteer reads a body only after the
|
||||
* response finished loading, with no timeout of its own, and a response that
|
||||
* never finishes (e.g. a `Cache-Control: no-store` response the page did not
|
||||
* read) would otherwise hold the tool mutex forever.
|
||||
*/
|
||||
export const RESPONSE_BODY_TIMEOUT_MS = 5000;
|
||||
|
||||
class ResponseBodyTimeoutError extends Error {
|
||||
readonly timeout: number;
|
||||
|
||||
constructor(timeout: number) {
|
||||
super(`Response body timed out after ${timeout} ms`);
|
||||
this.timeout = timeout;
|
||||
}
|
||||
}
|
||||
|
||||
function responseBodyTimeoutMessage(error: ResponseBodyTimeoutError): string {
|
||||
return `<Response body not available: the response did not finish loading within ${error.timeout} ms>`;
|
||||
}
|
||||
|
||||
export interface NetworkFormatterOptions {
|
||||
requestId?: number | string;
|
||||
@@ -28,6 +48,8 @@ export interface NetworkFormatterOptions {
|
||||
extension: '.network-request' | '.network-response',
|
||||
) => Promise<{filename: string}>;
|
||||
redactNetworkHeaders: boolean;
|
||||
/** Defaults to `RESPONSE_BODY_TIMEOUT_MS`. */
|
||||
responseBodyTimeout?: number;
|
||||
}
|
||||
|
||||
interface NetworkRequestConcise {
|
||||
@@ -116,9 +138,12 @@ export class NetworkFormatter {
|
||||
if (this.#options.responseFilePath) {
|
||||
let buffer: Uint8Array | undefined;
|
||||
try {
|
||||
buffer = await response.buffer();
|
||||
} catch {
|
||||
// Response body is not available anymore
|
||||
buffer = await this.#readResponseBody(response);
|
||||
} catch (error) {
|
||||
this.#responseBody =
|
||||
error instanceof ResponseBodyTimeoutError
|
||||
? responseBodyTimeoutMessage(error)
|
||||
: responseBodyNotAvailableMessage;
|
||||
}
|
||||
|
||||
if (buffer) {
|
||||
@@ -133,8 +158,6 @@ export class NetworkFormatter {
|
||||
'.network-response',
|
||||
);
|
||||
this.#responseBodyFilePath = result.filename;
|
||||
} else {
|
||||
this.#responseBody = responseBodyNotAvailableMessage;
|
||||
}
|
||||
} else {
|
||||
this.#responseBody = await this.#getFormattedResponseBody(
|
||||
@@ -238,7 +261,7 @@ export class NetworkFormatter {
|
||||
sizeLimit = BODY_CONTEXT_SIZE_LIMIT,
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const responseBuffer = await httpResponse.buffer();
|
||||
const responseBuffer = await this.#readResponseBody(httpResponse);
|
||||
|
||||
if (isUtf8(responseBuffer)) {
|
||||
const responseAsTest = responseBuffer.toString('utf-8');
|
||||
@@ -251,10 +274,31 @@ export class NetworkFormatter {
|
||||
}
|
||||
|
||||
return '<binary data>';
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseBodyTimeoutError) {
|
||||
return responseBodyTimeoutMessage(error);
|
||||
}
|
||||
return '<not available anymore>';
|
||||
}
|
||||
}
|
||||
|
||||
async #readResponseBody(httpResponse: HTTPResponse): Promise<Buffer> {
|
||||
const timeout =
|
||||
this.#options.responseBodyTimeout ?? RESPONSE_BODY_TIMEOUT_MS;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
httpResponse.buffer(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new ResponseBodyTimeoutError(timeout));
|
||||
}, timeout);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSizeLimitedString(text: string, sizeLimit: number): string {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {readFile, writeFile} from 'node:fs/promises';
|
||||
import {join} from 'node:path';
|
||||
import {describe, it} from 'node:test';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {NetworkFormatter} from '../../src/formatters/NetworkFormatter.js';
|
||||
import type {HTTPRequest} from '../../src/third_party/index.js';
|
||||
import {createTempDir, getMockRequest, getMockResponse} from '../utils.js';
|
||||
@@ -449,6 +451,67 @@ describe('NetworkFormatter', () => {
|
||||
assert.ok(result.includes(`Saved to ${resPath}.`));
|
||||
});
|
||||
|
||||
it('stops waiting for a response body that never finishes loading', async () => {
|
||||
const request = {
|
||||
method: () => 'GET',
|
||||
url: () => 'http://example.com',
|
||||
headers: () => ({}),
|
||||
hasPostData: () => false,
|
||||
response: () => ({
|
||||
status: () => 500,
|
||||
headers: () => ({}),
|
||||
buffer: () => new Promise<Buffer>(() => {}),
|
||||
}),
|
||||
failure: () => null,
|
||||
redirectChain: () => [],
|
||||
} as unknown as HTTPRequest;
|
||||
|
||||
const formatter = await NetworkFormatter.from(request, {
|
||||
fetchData: true,
|
||||
responseBodyTimeout: 10,
|
||||
redactNetworkHeaders: false,
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
formatter.toJSONDetailed().responseBody,
|
||||
'<Response body not available: the response did not finish loading within 10 ms>',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not save a response body that never finishes loading', async () => {
|
||||
const request = {
|
||||
method: () => 'GET',
|
||||
url: () => 'http://example.com',
|
||||
headers: () => ({}),
|
||||
hasPostData: () => false,
|
||||
response: () => ({
|
||||
status: () => 500,
|
||||
headers: () => ({}),
|
||||
buffer: () => new Promise<Buffer>(() => {}),
|
||||
}),
|
||||
failure: () => null,
|
||||
redirectChain: () => [],
|
||||
} as unknown as HTTPRequest;
|
||||
const resPath = '/tmp/never-written';
|
||||
const saveFile = sinon.stub().resolves({filename: resPath});
|
||||
|
||||
const formatter = await NetworkFormatter.from(request, {
|
||||
fetchData: true,
|
||||
responseFilePath: resPath,
|
||||
responseBodyTimeout: 10,
|
||||
saveFile,
|
||||
redactNetworkHeaders: false,
|
||||
});
|
||||
|
||||
const json = formatter.toJSONDetailed();
|
||||
sinon.assert.notCalled(saveFile);
|
||||
assert.strictEqual(json.responseBodyFilePath, undefined);
|
||||
assert.strictEqual(
|
||||
json.responseBody,
|
||||
'<Response body not available: the response did not finish loading within 10 ms>',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles missing bodies with filepath', async () => {
|
||||
const request = {
|
||||
method: () => 'POST',
|
||||
|
||||
Reference in new issue
Block a user