Compare commits
15 Commits
main
...
clawsweepe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
357a0b68d5 | ||
|
|
db2be33671 | ||
|
|
7f96ecbcd9 | ||
|
|
fb24965011 | ||
|
|
ac1e1125f8 | ||
|
|
9ff7fe00fb | ||
|
|
c967364744 | ||
|
|
2b604d8061 | ||
|
|
100c364cf6 | ||
|
|
65e4051662 | ||
|
|
f0a0196c4d | ||
|
|
1586bc9209 | ||
|
|
2faaeb36e4 | ||
|
|
0b3ba2b7d5 | ||
|
|
9fe947a6be |
57
src/cli.ts
57
src/cli.ts
@ -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);
|
||||
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user