Compare commits

...

15 Commits

Author SHA1 Message Date
clawsweeper
357a0b68d5 fix: flush CLI stdio before forced exit 2026-06-23 11:12:31 +00:00
clawsweeper
db2be33671 fix: flush CLI stdio before forced exit 2026-06-23 10:54:02 +00:00
clawsweeper
7f96ecbcd9 fix: flush CLI stdio before forced exit 2026-06-23 10:40:03 +00:00
clawsweeper
fb24965011 fix: flush CLI stdio before forced exit 2026-06-23 10:27:20 +00:00
clawsweeper
ac1e1125f8 fix: flush CLI stdio before forced exit 2026-06-23 10:06:26 +00:00
clawsweeper
9ff7fe00fb fix: flush CLI stdio before forced exit 2026-06-23 09:49:04 +00:00
clawsweeper
c967364744 fix: flush CLI stdio before forced exit 2026-06-23 09:29:29 +00:00
clawsweeper
2b604d8061 fix: flush CLI stdio before forced exit 2026-06-23 09:11:28 +00:00
clawsweeper
100c364cf6 fix: flush CLI stdio before forced exit 2026-06-23 08:55:54 +00:00
clawsweeper
65e4051662 fix: flush CLI stdio before forced exit 2026-06-23 08:43:44 +00:00
clawsweeper
f0a0196c4d fix: flush CLI stdio before forced exit 2026-06-23 08:27:32 +00:00
clawsweeper
1586bc9209 fix: flush CLI stdio before forced exit 2026-06-23 08:13:41 +00:00
clawsweeper
2faaeb36e4 fix: flush CLI stdio before forced exit 2026-06-23 08:00:32 +00:00
clawsweeper
0b3ba2b7d5 fix: flush CLI stdio before forced exit 2026-06-23 07:46:52 +00:00
clawsweeper
9fe947a6be fix: flush CLI stdio before forced exit 2026-06-23 07:33:35 +00:00
2 changed files with 154 additions and 2 deletions

View File

@ -14,6 +14,8 @@ export { extractListFlags } from './cli/list-flags.js';
export { resolveCallTimeout } from './cli/timeouts.js';
const FORCE_EXIT_GRACE_MS = 50;
const FORCE_EXIT_STDIO_FLUSH_TIMEOUT_MS = 500;
const FORCE_EXIT_STDIO_SETTLE_MS = 0;
const DAEMON_FAST_PATH_SERVERS = new Set(['chrome-devtools', 'mobile-mcp', 'playwright']);
export async function handleAuth(
@ -356,6 +358,9 @@ async function closeRuntimeAfterCommand(
}, FORCE_EXIT_GRACE_MS);
}
};
if (shouldForceExit) {
await flushProcessStdio();
}
if (DEBUG_HANG) {
dumpActiveHandles('after terminateChildProcesses');
scheduleForcedExit();
@ -368,6 +373,58 @@ async function closeRuntimeAfterCommand(
}
}
async function flushProcessStdio(timeoutMs = FORCE_EXIT_STDIO_FLUSH_TIMEOUT_MS): Promise<void> {
await Promise.allSettled([
flushWriteStream(process.stdout, timeoutMs, FORCE_EXIT_STDIO_SETTLE_MS),
flushWriteStream(process.stderr, timeoutMs, FORCE_EXIT_STDIO_SETTLE_MS),
]);
}
function flushWriteStream(stream: NodeJS.WriteStream, timeoutMs: number, settleMs: number): Promise<void> {
if (!stream.writable || stream.destroyed || stream.writableEnded) {
return Promise.resolve();
}
if (stream.writableLength === 0 && !stream.writableNeedDrain) {
return Promise.resolve();
}
return new Promise((resolve) => {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
stream.off('drain', finishAfterDrain);
stream.off('error', cleanup);
resolve();
};
const finishAfterDrain = () => {
setImmediate(cleanup);
};
const finishWhenDrained = () => {
if (settled) {
return;
}
if (stream.destroyed || stream.writableEnded || (!stream.writableNeedDrain && stream.writableLength === 0)) {
cleanup();
return;
}
setImmediate(finishWhenDrained);
};
timeout = setTimeout(cleanup, stream.writableNeedDrain ? timeoutMs : settleMs);
stream.once('drain', finishAfterDrain);
stream.once('error', cleanup);
setImmediate(finishWhenDrained);
});
}
function wrapperArgsBeforeSeparator(args: readonly string[]): string[] {
const separatorIndex = args.indexOf('--');
return separatorIndex === -1 ? [...args] : args.slice(0, separatorIndex);

View File

@ -12,6 +12,9 @@ const testRequire = createRequire(import.meta.url);
const MCP_SERVER_MODULE = pathToFileURL(testRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js')).href;
const STDIO_SERVER_MODULE = pathToFileURL(testRequire.resolve('@modelcontextprotocol/sdk/server/stdio.js')).href;
const ZOD_MODULE = pathToFileURL(path.join(process.cwd(), 'node_modules', 'zod', 'index.js')).href;
// This harness patches Node's stdout internals to synthesize an emitted EPIPE.
// Keep the cross-platform coverage in the large-output test below.
const itWithMutableStdoutInternals = process.platform === 'win32' ? it.skip : it;
async function ensureDistBuilt(): Promise<void> {
try {
@ -39,6 +42,83 @@ function runCli(args: string[], configPath: string): Promise<{ stdout: string; s
});
}
async function runCliWithCleanupStdoutError(
configPath: string,
tempDir: string
): Promise<{ stdout: string; stderr: string; code: number }> {
const script = `
import { pathToFileURL } from 'node:url';
const [cliEntry, configPath] = process.argv.slice(2);
process.env.MCPORTER_DISABLE_AUTORUN = '1';
let cleanupErrorListenerSeen = false;
let cleanupEmptyWriteSeen = false;
let cliRunning = false;
Object.defineProperty(process.stdout, 'writableNeedDrain', {
configurable: true,
get: () => false,
});
Object.defineProperty(process.stdout, 'writableLength', {
configurable: true,
get: () => 1,
});
const originalOnce = process.stdout.once.bind(process.stdout);
process.stdout.once = (event, listener) => {
const result = originalOnce(event, listener);
if (cliRunning && event === 'error' && !cleanupErrorListenerSeen) {
cleanupErrorListenerSeen = true;
const error = new Error('write EPIPE');
error.code = 'EPIPE';
process.stdout.emit('error', error);
}
return result;
};
const originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = (chunk, encoding, callback) => {
if (cliRunning && chunk === '') {
cleanupEmptyWriteSeen = true;
}
return originalWrite(chunk, encoding, callback);
};
const { runCli } = await import(pathToFileURL(cliEntry).href);
cliRunning = true;
await runCli(['--config', configPath, 'list', 'force-exit', '--schema', '--output', 'json']);
if (!cleanupErrorListenerSeen) {
console.error('expected force-exit cleanup to observe stdout errors');
process.exitCode = 1;
}
if (cleanupEmptyWriteSeen) {
console.error('expected force-exit cleanup not to write to stdout');
process.exitCode = 1;
}
`;
const scriptPath = path.join(tempDir, 'cleanup-stdout-error.mjs');
await fs.writeFile(scriptPath, script, 'utf8');
return new Promise((resolve, reject) => {
execFile(
process.execPath,
[scriptPath, CLI_ENTRY, configPath],
{
cwd: process.cwd(),
env: process.env,
maxBuffer: 1024 * 1024,
timeout: 15000,
},
(error, stdout, stderr) => {
const code = typeof error?.code === 'number' ? error.code : 0;
if (error && typeof error.code !== 'number') {
reject(error);
return;
}
resolve({ stdout, stderr, code });
}
);
});
}
describe('mcporter forced exit behavior', () => {
let tempDir: string;
let configPath: string;
@ -48,7 +128,7 @@ describe('mcporter forced exit behavior', () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-force-exit-'));
const serverScriptPath = path.join(tempDir, 'force-exit-server.mjs');
configPath = path.join(tempDir, 'config.json');
const longDescription = 'Large schema tool description. '.repeat(30);
const longDescription = 'Large schema tool description. '.repeat(40);
await fs.writeFile(
serverScriptPath,
@ -131,10 +211,25 @@ await server.connect(transport);
const result = await runCli(['list', 'force-exit', '--schema', '--output', 'json'], configPath);
expect(result.code).toBe(0);
expect(result.stderr).toBe('');
expect(Buffer.byteLength(result.stdout)).toBeGreaterThan(8192);
expect(Buffer.byteLength(result.stdout)).toBeGreaterThan(64 * 1024);
const payload = JSON.parse(result.stdout) as { tools: Array<{ name: string }> };
expect(payload.tools).toHaveLength(65);
expect(payload.tools.at(-1)?.name).toBe('tool_63');
}, 20000);
itWithMutableStdoutInternals(
'does not fail when stdout reports EPIPE during force exit cleanup',
async () => {
const result = await runCliWithCleanupStdoutError(configPath, tempDir);
if (result.code !== 0 || result.stderr !== '') {
throw new Error(
`cleanup stdout EPIPE command failed with code ${result.code}; stdout bytes=${Buffer.byteLength(result.stdout)}; stderr=${JSON.stringify(result.stderr)}`
);
}
expect(result.code).toBe(0);
expect(result.stderr).toBe('');
},
20000
);
});