fix(core): Avoid Agent.close() deadlock in instance-ai web-research fetch (no-changelog) (#30105)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
José Braulio González Valido 2026-05-11 13:40:49 +01:00 committed by GitHub
parent 3123f2551b
commit 5bf5f03453
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 33 additions and 1 deletions

View File

@ -302,4 +302,35 @@ describe('fetchAndExtract', () => {
).rejects.toThrow(/restricted IP/i);
});
});
it('does not deadlock when the response body streams in chunks after fetch resolves', async () => {
const encoder = new TextEncoder();
const slowBody = new ReadableStream({
async start(controller) {
for (const part of ['<html>', '<body>', 'hello world', '</body>', '</html>']) {
await new Promise((resolve) => setTimeout(resolve, 5));
controller.enqueue(encoder.encode(part));
}
controller.close();
},
});
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
url: 'https://example.com/slow',
headers: new Headers({ 'content-type': 'text/html' }),
body: slowBody,
} as unknown as Response);
const result = await Promise.race([
fetchAndExtract('https://example.com/slow', { ssrf }),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('fetchAndExtract deadlocked')), 2000),
),
]);
expect(result.content).toContain('hello world');
});
});

View File

@ -72,7 +72,8 @@ export async function fetchAndExtract(
});
} finally {
clearTimeout(timeout);
await dispatcher.close();
// Fire-and-forget — awaiting Agent.close() deadlocks against an unread body.
void dispatcher.close().catch(() => {});
}
// Follow redirects manually so each hop is SSRF-checked