Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/runtime/process-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
}
Expand All @@ -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();
}
Expand Down Expand Up @@ -763,7 +769,7 @@ export class ProcessIO extends BoundedContext implements Transport {
private writeToStdin(data: string): Promise<void> {
return new Promise<void>((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;
}

Expand All @@ -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}`)));
}
});
}
Expand All @@ -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();
Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*/
Expand Down
19 changes: 19 additions & 0 deletions test/adversarial_playground.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
21 changes: 21 additions & 0 deletions test/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand Down