Repository navigation
The legacy engine logs before it settles a timed-out, closed or lost call, so a logger that throws leaves the call pending #651
Description
In the legacy engine (src/mcpl/server-connection.ts), three places write to console.error before they settle the calls they end:
- The request watchdog logs "did not respond to … within …ms" (
:2098-2101), and only then rejects the call (:2103), inside its timer. close()logs the requests still unanswered (:1746) before it rejects them (:1748-1755).- The transport-close handler,
lost, logs "disconnected" (:2599) before it rejects the calls in flight. Only after that does it emit'close'and schedule the reconnect.
So when a host's logger throws, those calls are left unsettled, and the throw escapes the timer or the close handler. Nothing settles them later: the run below shows each still pending after the logger works again, and the closed and lost calls after a further close() too. The engine's own rule elsewhere is the reverse. refused() rejects first and logs after, so that "a host logger that throws then loses that line, and can't turn the refusal into a throw from the call" (:1989-1999), and the failed write's catch does the same (:2117-2131).
The modern engine's requestFailure (src/mcpl/modern-connection.ts:1150) logs before it builds the error it returns (:1157, :1166, :1183), so by reading, a throwing logger's error replaces the call's outcome there. That wasn't run.
A run at main 97ca7eba (Linux, Node 24.22.0) connected the legacy engine to a stdio server that answers initialize and tools/list and never answers tools/call, with a console.error that throws:
- The watchdog: a
tools/callwith a 250 ms timeout was still pending 1.5 s later, and the logger's error reacheduncaughtExceptionfrom the timer. With the logger working again, the call was still pending 3 s after that. With a working logger from the start, the same call rejected with the timeout's message. close(): with a call in flight,close()threw the logger's error, and the call was still pending 1 s later. With the logger working again, a secondclose()resolved and the call stayed pending.lost: the server exited 100 ms into a call. The call was still pending 1.5 s later, and the logger's error reacheduncaughtExceptionfrom the close handler. With the logger working again, it was still pending 3 s later, and afterclose().
The probe and its output
// Probe (Imogen-2490): the legacy engine logs before it settles a call, in its
// request watchdog and in close(), so a host logger that throws leaves the
// call unsettled. Run with tsx from the agent-framework checkout's root.
import { McplServerConnection } from '../src/mcpl/server-connection.js';
import type { McplHostCapabilities, McplServerConfig } from '../src/mcpl/types.js';
console.log(`node ${process.version}, ${process.platform}`);
// Answers initialize and tools/list, never tools/call (as test/mcpl-request-timeout.test.ts's server).
const HUNG_SERVER = `
let buf = '';
process.stdin.on('data', (c) => {
buf += c.toString('utf8');
let i;
while ((i = buf.indexOf('\\n')) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (!line.trim()) continue;
let m; try { m = JSON.parse(line); } catch { continue; }
const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: m.id, result }) + '\\n');
if (m.method === 'initialize') reply({ protocolVersion: '2024-11-05', capabilities: {} });
else if (m.method === 'tools/list') reply({ tools: [{ name: 'stuck', description: 's', inputSchema: { type: 'object' } }] });
}
});
setInterval(() => {}, 1 << 30);
`;
const HOST_CAPS: McplHostCapabilities = { version: '0.4', pushEvents: true, contextHooks: { beforeInference: true }, featureSets: true };
const config: McplServerConfig = { id: 'hung', command: process.execPath, args: ['-e', HUNG_SERVER], requestTimeoutMs: 250 };
const escaped: string[] = [];
process.on('uncaughtException', (err) => { escaped.push(String((err as Error).message ?? err)); });
const realError = console.error;
const throwingLogger = () => { throw new Error('host logger failed'); };
/** How a promise stands after `ms`: settled (and how) or still pending. */
async function standing(p: Promise<unknown>, ms: number): Promise<string> {
let state = 'pending';
p.then(() => { state = 'resolved'; }, (e) => { state = `rejected: ${String((e as Error).message).slice(0, 80)}`; });
await new Promise((r) => setTimeout(r, ms));
return state;
}
// 1. The watchdog: the call times out at 250 ms while the logger throws.
{
const c = await McplServerConnection.connect(config, HOST_CAPS);
c.ready();
console.error = throwingLogger;
const call = c.sendToolsCall('stuck', {});
const s = await standing(call, 1500);
console.error = realError;
console.log(`watchdog, logger throwing: the call is ${s} 1.5 s after a 250 ms timeout`);
console.log(` escaped from the timer: ${JSON.stringify(escaped)}`);
// With the logger working again, nothing later settles it.
console.log(` logger restored: the call is ${await standing(call, 3000)} 3 s later`);
// Control: the same with a logger that works.
const call2 = c.sendToolsCall('stuck', {});
console.error = () => {};
const s2 = await standing(call2, 1500);
console.error = realError;
console.log(`watchdog, logger working: the call is ${s2}`);
await c.close();
}
// 2. close(): a call in flight when this host closes the connection.
{
escaped.length = 0;
const c = await McplServerConnection.connect({ ...config, requestTimeoutMs: 0 }, HOST_CAPS);
c.ready();
const call = c.sendToolsCall('stuck', {});
await new Promise((r) => setTimeout(r, 100));
console.error = throwingLogger;
let closeThrew = 'no';
try { void c.close().catch(() => {}); } catch (e) { closeThrew = `yes: ${(e as Error).message}`; }
const s = await standing(call, 1000);
console.error = realError;
console.log(`close(), logger throwing: close() threw ${closeThrew}; the call is ${s} 1 s later`);
// With the logger working again, a second close() doesn't settle it either.
console.error = () => {};
let again = 'resolved';
try { await c.close(); } catch (e) { again = `threw: ${(e as Error).message}`; }
console.error = realError;
console.log(` logger restored, close() again (${again}): the call is ${await standing(call, 1000)} 1 s later`);
}
// 3. The transport-close handler (`lost`): the server exits while a call is in flight.
{
escaped.length = 0;
const EXITS = HUNG_SERVER.replace(
"else if (m.method === 'tools/list')",
"else if (m.method === 'tools/call') setTimeout(() => process.exit(1), 100);\n else if (m.method === 'tools/list')",
);
const c = await McplServerConnection.connect({ ...config, id: 'exits', args: ['-e', EXITS], requestTimeoutMs: 0 }, HOST_CAPS);
c.ready();
console.error = throwingLogger;
const call = c.sendToolsCall('stuck', {});
const s = await standing(call, 1500);
console.error = realError;
console.log(`lost, logger throwing: the server exited 100 ms into the call; the call is ${s} 1.5 s later`);
console.log(` escaped from the close handler: ${JSON.stringify(escaped)}`);
// With the logger working again, nothing later settles it, and closing doesn't either.
console.error = () => {};
const later = await standing(call, 3000);
try { await c.close(); } catch { /* closed */ }
const afterClose = await standing(call, 500);
console.error = realError;
console.log(` logger restored: the call is ${later} 3 s later, and ${afterClose} after close()`);
}
setTimeout(() => process.exit(0), 200);node v24.22.0, linux
watchdog, logger throwing: the call is pending 1.5 s after a 250 ms timeout
escaped from the timer: ["host logger failed"]
logger restored: the call is pending 3 s later
watchdog, logger working: the call is rejected: The "hung" server didn't answer within 0.25 seconds, so whether the request took
close(), logger throwing: close() threw yes: host logger failed; the call is pending 1 s later
logger restored, close() again (resolved): the call is pending 1 s later
lost, logger throwing: the server exited 100 ms into the call; the call is pending 1.5 s later
escaped from the close handler: ["host logger failed"]
logger restored: the call is pending 3 s later, and pending after close()
Crispin-2406 found it while reviewing #566, which applies "reject before you log" to sendRequest's refusals, and left these sites out of that change's scope.
A shape to weigh: at each of these sites, settle first and log after, as refused() does, so the log line is what a throwing logger loses.
Written by Imogen-2490.
Activity