diff --git a/src/runtime/process-io.ts b/src/runtime/process-io.ts index 4178c8f..c87b275 100644 --- a/src/runtime/process-io.ts +++ b/src/runtime/process-io.ts @@ -676,7 +676,9 @@ export class ProcessIO extends BoundedContext implements Transport { */ private handleStdinError(err: Error): void { // EPIPE means process died - const error = new BridgeProtocolError(`stdin error: ${err.message}`); + const error = new BridgeProtocolError( + this.withStderrTail(`stdin error: ${err.message}`) + ); // Reject all pending writes for (const queued of this.writeQueue) { @@ -697,7 +699,9 @@ export class ProcessIO extends BoundedContext implements Transport { * This can occur during pipe errors or when the process crashes. */ private handleStdoutError(err: Error): void { - const error = new BridgeProtocolError(`stdout error: ${err.message}`); + const error = new BridgeProtocolError( + this.withStderrTail(`stdout error: ${err.message}`) + ); this.rejectAllPending(error); this.markForRestart(); } @@ -708,7 +712,9 @@ export class ProcessIO extends BoundedContext implements Transport { */ private handleStderrError(err: Error): void { // Stderr errors are less critical but still indicate process health issues - const error = new BridgeProtocolError(`stderr error: ${err.message}`); + const error = new BridgeProtocolError( + this.withStderrTail(`stderr error: ${err.message}`) + ); this.rejectAllPending(error); this.markForRestart(); } @@ -763,7 +769,7 @@ export class ProcessIO extends BoundedContext implements Transport { private writeToStdin(data: string): Promise { return new Promise((resolve, reject) => { if (!this.process?.stdin || this.processExited) { - reject(new BridgeProtocolError('Process stdin not available')); + reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available'))); return; } @@ -788,7 +794,8 @@ export class ProcessIO extends BoundedContext implements Transport { } catch (err) { // Synchronous write error (e.g., EPIPE) this.markForRestart(); - reject(new BridgeProtocolError(`Write error: ${err instanceof Error ? err.message : 'unknown'}`)); + const errorMessage = err instanceof Error ? err.message : 'unknown'; + reject(new BridgeProtocolError(this.withStderrTail(`Write error: ${errorMessage}`))); } }); } @@ -804,7 +811,7 @@ export class ProcessIO extends BoundedContext implements Transport { // Process died - reject all queued writes for (const q of this.writeQueue) { this.clearQueuedWriteTimeout(q); - q.reject(new BridgeProtocolError('Process stdin not available')); + q.reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available'))); } this.writeQueue.length = 0; this.markForRestart(); @@ -841,8 +848,9 @@ export class ProcessIO extends BoundedContext implements Transport { } } catch (err) { // Synchronous write error (e.g., EPIPE) - reject this and all remaining writes + const errorMessage = err instanceof Error ? err.message : 'unknown'; const error = new BridgeProtocolError( - `Write error: ${err instanceof Error ? err.message : 'unknown'}` + this.withStderrTail(`Write error: ${errorMessage}`) ); queued.reject(error); for (const q of this.writeQueue) { @@ -867,6 +875,14 @@ export class ProcessIO extends BoundedContext implements Transport { return this.stderrBuffer.trim(); } + /** + * Append stderr context when available. + */ + private withStderrTail(message: string): string { + const stderrTail = this.getStderrTail(); + return stderrTail ? `${message}. Stderr:\n${stderrTail}` : message; + } + /** * Handle a protocol error by rejecting all pending requests. */ diff --git a/test/adversarial_playground.test.ts b/test/adversarial_playground.test.ts index 560dbc6..98e7edf 100644 --- a/test/adversarial_playground.test.ts +++ b/test/adversarial_playground.test.ts @@ -157,6 +157,25 @@ describeAdversarial('Adversarial playground', () => { testTimeoutMs ); + it( + 'surfaces invalid TYWRAP_CODEC_MAX_BYTES as an explicit startup error', + async () => { + const bridge = await createBridge({ + env: { TYWRAP_CODEC_MAX_BYTES: 'not-a-number' }, + }); + if (!bridge) return; + + try { + await expect(callAdversarial(bridge, 'echo', ['value'])).rejects.toThrow( + /TYWRAP_CODEC_MAX_BYTES/ + ); + } finally { + await bridge.dispose(); + } + }, + testTimeoutMs + ); + it( 'rejects requests that exceed TYWRAP_REQUEST_MAX_BYTES', async () => { diff --git a/test/transport.test.ts b/test/transport.test.ts index 92ac458..c8e96c6 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -404,6 +404,7 @@ describe('ProcessIO', () => { interface ProcessIOInternals { _state: string; processExited: boolean; + stderrBuffer: string; process: { stdin: { write: (data: string) => boolean } } | null; handleStdinDrain: () => void; handleResponseLine: (line: string) => void; @@ -583,6 +584,26 @@ describe('ProcessIO', () => { await expect(firstPending).resolves.toContain(`"id":${firstId}`); await expect(secondPending).resolves.toContain(`"id":${secondId}`); }); + + it('includes stderr diagnostics when stdin write fails', async () => { + const transport = new ProcessIO({ bridgeScript: '/path/to/bridge.py' }); + + const internals = transport as unknown as ProcessIOInternals; + internals._state = 'ready'; + internals.processExited = false; + internals.stderrBuffer = + 'CodecMaxBytesParseError: TYWRAP_CODEC_MAX_BYTES must be an integer byte count'; + internals.process = { + stdin: { + write: (): boolean => { + throw new Error('write EPIPE'); + }, + }, + }; + + const message = JSON.stringify(createValidMessage({ id: 303 })); + await expect(transport.send(message, 1000)).rejects.toThrow(/TYWRAP_CODEC_MAX_BYTES/); + }); }); describe('abstract method stubs', () => {