fix: cli input stream handling and error management, improve e2e and unit tests

This commit is contained in:
mingholy.lmh 2026-01-23 13:56:38 +08:00
parent 6eb16c0bcf
commit f578ff07a2
13 changed files with 741 additions and 150 deletions

View file

@ -663,7 +663,21 @@ export class Query implements AsyncIterable<SDKMessage> {
},
);
this.transport.write(serializeJsonLine(request));
try {
this.transport.write(serializeJsonLine(request));
} catch (error) {
const pending = this.pendingControlRequests.get(requestId);
if (pending) {
clearTimeout(pending.timeout);
this.pendingControlRequests.delete(requestId);
}
const errorMsg = error instanceof Error ? error.message : String(error);
logger.error(`Failed to send control request: ${errorMsg}`);
return Promise.reject(
new Error(`Failed to send control request: ${errorMsg}`),
);
}
return responsePromise;
}
@ -687,7 +701,15 @@ export class Query implements AsyncIterable<SDKMessage> {
},
};
this.transport.write(serializeJsonLine(response));
try {
this.transport.write(serializeJsonLine(response));
} catch (error) {
// Write failed - log and ignore since response cannot be delivered
const errorMsg = error instanceof Error ? error.message : String(error);
logger.warn(
`Failed to send control response for request ${requestId}: ${errorMsg}`,
);
}
}
async close(): Promise<void> {
@ -790,11 +812,7 @@ export class Query implements AsyncIterable<SDKMessage> {
* The timeout ensures we don't hang indefinitely - either the turn proceeds
* normally, or it fails with a timeout, but Promise.race will always resolve.
*/
if (
!this.isSingleTurn &&
this.sdkMcpTransports.size > 0 &&
this.firstResultReceivedPromise
) {
if (this.firstResultReceivedPromise) {
const streamCloseTimeout =
this.options.timeout?.streamClose ?? DEFAULT_STREAM_CLOSE_TIMEOUT;
let timeoutId: NodeJS.Timeout | undefined;

View file

@ -18,6 +18,7 @@ export class ProcessTransport implements Transport {
private ready = false;
private _exitError: Error | null = null;
private closed = false;
private inputClosed = false;
private abortController: AbortController;
private processExitHandler: (() => void) | null = null;
private abortHandler: (() => void) | null = null;
@ -210,6 +211,7 @@ export class ProcessTransport implements Transport {
this.ready = false;
this.closed = true;
this.inputClosed = true;
}
async waitForExit(): Promise<void> {
@ -273,8 +275,16 @@ export class ProcessTransport implements Transport {
throw new Error('Cannot write to closed transport');
}
if (this.childStdin.writableEnded) {
throw new Error('Cannot write to ended stream');
if (this.inputClosed) {
throw new Error('Input stream closed');
}
if (this.childStdin.writableEnded || this.childStdin.destroyed) {
this.inputClosed = true;
logger.warn(
`Cannot write to ${this.childStdin.writableEnded ? 'ended' : 'destroyed'} stdin stream, ignoring write`,
);
return;
}
if (this.childProcess?.killed || this.childProcess?.exitCode !== null) {
@ -301,10 +311,25 @@ export class ProcessTransport implements Transport {
logger.debug(`Write successful (${message.length} bytes)`);
}
} catch (error) {
// Check if this is a stream-closed error (EPIPE, ERR_STREAM_WRITE_AFTER_END, etc.)
const errorMsg = error instanceof Error ? error.message : String(error);
const isStreamClosedError =
errorMsg.includes('EPIPE') ||
errorMsg.includes('ERR_STREAM_WRITE_AFTER_END') ||
errorMsg.includes('write after end');
if (isStreamClosedError) {
// Soft-fail: log and return without throwing or changing ready state
this.inputClosed = true;
logger.warn(`Stream closed, cannot write: ${errorMsg}`);
return;
}
// For other errors, maintain original behavior
this.ready = false;
const errorMsg = `Failed to write to stdin: ${error instanceof Error ? error.message : String(error)}`;
logger.error(errorMsg);
throw new Error(errorMsg);
const fullErrorMsg = `Failed to write to stdin: ${errorMsg}`;
logger.error(fullErrorMsg);
throw new Error(fullErrorMsg);
}
}
@ -344,6 +369,7 @@ export class ProcessTransport implements Transport {
endInput(): void {
if (this.childStdin) {
this.childStdin.end();
this.inputClosed = true;
}
}