feat(rustbridge): add streaming responses and robust large-payload/backpressure handling to RustBridge

This commit is contained in:
2026-02-11 00:12:56 +00:00
parent dcb88ef4b5
commit 5fb991ff51
11 changed files with 798 additions and 84 deletions

View File

@@ -2,22 +2,46 @@
/**
* Mock "Rust binary" for testing the RustBridge IPC protocol.
* Reads JSON lines from stdin, writes JSON lines to stdout.
* Reads JSON lines from stdin via Buffer-based scanner, writes JSON lines to stdout.
* Emits a ready event on startup.
*/
import { createInterface } from 'readline';
// Emit ready event
const readyEvent = JSON.stringify({ event: 'ready', data: { version: '1.0.0' } });
process.stdout.write(readyEvent + '\n');
const rl = createInterface({ input: process.stdin });
// Buffer-based newline scanner for stdin (mirrors the RustBridge approach)
let stdinBuffer = Buffer.alloc(0);
rl.on('line', (line) => {
process.stdin.on('data', (chunk) => {
stdinBuffer = Buffer.concat([stdinBuffer, chunk]);
let newlineIndex;
while ((newlineIndex = stdinBuffer.indexOf(0x0A)) !== -1) {
const lineBuffer = stdinBuffer.subarray(0, newlineIndex);
stdinBuffer = stdinBuffer.subarray(newlineIndex + 1);
const line = lineBuffer.toString('utf8').trim();
if (line) {
handleLine(line);
}
}
});
/**
* Backpressure-aware write to stdout.
*/
function writeResponse(data) {
const json = JSON.stringify(data) + '\n';
if (!process.stdout.write(json)) {
// Wait for drain before continuing
process.stdout.once('drain', () => {});
}
}
function handleLine(line) {
let request;
try {
request = JSON.parse(line.trim());
request = JSON.parse(line);
} catch {
return;
}
@@ -26,35 +50,53 @@ rl.on('line', (line) => {
if (method === 'echo') {
// Echo back the params as result
const response = JSON.stringify({ id, success: true, result: params });
process.stdout.write(response + '\n');
writeResponse({ id, success: true, result: params });
} else if (method === 'largeEcho') {
// Echo back params (same as echo, named distinctly for large payload tests)
writeResponse({ id, success: true, result: params });
} else if (method === 'error') {
// Return an error
const response = JSON.stringify({ id, success: false, error: 'Test error message' });
process.stdout.write(response + '\n');
writeResponse({ id, success: false, error: 'Test error message' });
} else if (method === 'emitEvent') {
// Emit a custom event, then respond with success
const event = JSON.stringify({ event: params.eventName, data: params.eventData });
process.stdout.write(event + '\n');
const response = JSON.stringify({ id, success: true, result: null });
process.stdout.write(response + '\n');
writeResponse({ event: params.eventName, data: params.eventData });
writeResponse({ id, success: true, result: null });
} else if (method === 'slow') {
// Respond after a delay
setTimeout(() => {
const response = JSON.stringify({ id, success: true, result: { delayed: true } });
process.stdout.write(response + '\n');
writeResponse({ id, success: true, result: { delayed: true } });
}, 100);
} else if (method === 'streamEcho') {
// Send params.count stream chunks, then final response
const count = params.count || 0;
let sent = 0;
const interval = setInterval(() => {
if (sent < count) {
writeResponse({ id, stream: true, data: { index: sent, value: `chunk_${sent}` } });
sent++;
} else {
clearInterval(interval);
writeResponse({ id, success: true, result: { totalChunks: count } });
}
}, 10);
} else if (method === 'streamError') {
// Send 1 chunk, then error
writeResponse({ id, stream: true, data: { index: 0, value: 'before_error' } });
setTimeout(() => {
writeResponse({ id, success: false, error: 'Stream error after chunk' });
}, 20);
} else if (method === 'streamEmpty') {
// Zero chunks, immediate final response
writeResponse({ id, success: true, result: { totalChunks: 0 } });
} else if (method === 'exit') {
// Graceful exit
const response = JSON.stringify({ id, success: true, result: null });
process.stdout.write(response + '\n');
writeResponse({ id, success: true, result: null });
process.exit(0);
} else {
// Unknown command
const response = JSON.stringify({ id, success: false, error: `Unknown method: ${method}` });
process.stdout.write(response + '\n');
writeResponse({ id, success: false, error: `Unknown method: ${method}` });
}
});
}
// Handle SIGTERM gracefully
process.on('SIGTERM', () => {

View File

@@ -9,10 +9,14 @@ const mockBinaryPath = path.join(testDir, 'helpers/mock-rust-binary.mjs');
// Define the command types for our mock binary
type TMockCommands = {
echo: { params: Record<string, any>; result: Record<string, any> };
largeEcho: { params: Record<string, any>; result: Record<string, any> };
error: { params: {}; result: never };
emitEvent: { params: { eventName: string; eventData: any }; result: null };
slow: { params: {}; result: { delayed: boolean } };
exit: { params: {}; result: null };
streamEcho: { params: { count: number }; chunk: { index: number; value: string }; result: { totalChunks: number } };
streamError: { params: {}; chunk: { index: number; value: string }; result: never };
streamEmpty: { params: {}; chunk: never; result: { totalChunks: number } };
};
tap.test('should spawn and receive ready event', async () => {
@@ -188,4 +192,249 @@ tap.test('should emit exit event when process exits', async () => {
expect(bridge.running).toBeFalse();
});
tap.test('should handle 1MB payload round-trip', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
readyTimeoutMs: 5000,
requestTimeoutMs: 30000,
});
await bridge.spawn();
// Create a ~1MB payload
const largeString = 'x'.repeat(1024 * 1024);
const result = await bridge.sendCommand('largeEcho', { data: largeString });
expect(result.data).toEqual(largeString);
expect(result.data.length).toEqual(1024 * 1024);
bridge.kill();
});
tap.test('should handle 10MB payload round-trip', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
readyTimeoutMs: 5000,
requestTimeoutMs: 60000,
});
await bridge.spawn();
// Create a ~10MB payload
const largeString = 'y'.repeat(10 * 1024 * 1024);
const result = await bridge.sendCommand('largeEcho', { data: largeString });
expect(result.data).toEqual(largeString);
expect(result.data.length).toEqual(10 * 1024 * 1024);
bridge.kill();
});
tap.test('should reject outbound messages exceeding maxPayloadSize', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
readyTimeoutMs: 5000,
maxPayloadSize: 1000,
});
await bridge.spawn();
let threw = false;
try {
await bridge.sendCommand('largeEcho', { data: 'z'.repeat(2000) });
} catch (err: any) {
threw = true;
expect(err.message).toInclude('maxPayloadSize');
}
expect(threw).toBeTrue();
bridge.kill();
});
tap.test('should handle multiple large concurrent commands', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
readyTimeoutMs: 5000,
requestTimeoutMs: 30000,
});
await bridge.spawn();
const size = 500 * 1024; // 500KB each
const results = await Promise.all([
bridge.sendCommand('largeEcho', { data: 'a'.repeat(size), id: 1 }),
bridge.sendCommand('largeEcho', { data: 'b'.repeat(size), id: 2 }),
bridge.sendCommand('largeEcho', { data: 'c'.repeat(size), id: 3 }),
]);
expect(results[0].data.length).toEqual(size);
expect(results[0].data[0]).toEqual('a');
expect(results[1].data.length).toEqual(size);
expect(results[1].data[0]).toEqual('b');
expect(results[2].data.length).toEqual(size);
expect(results[2].data[0]).toEqual('c');
bridge.kill();
});
// === Streaming tests ===
tap.test('streaming: should receive chunks via for-await-of and final result', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
readyTimeoutMs: 5000,
requestTimeoutMs: 10000,
});
await bridge.spawn();
const stream = bridge.sendCommandStreaming('streamEcho', { count: 5 });
const chunks: Array<{ index: number; value: string }> = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
expect(chunks.length).toEqual(5);
for (let i = 0; i < 5; i++) {
expect(chunks[i].index).toEqual(i);
expect(chunks[i].value).toEqual(`chunk_${i}`);
}
const result = await stream.result;
expect(result.totalChunks).toEqual(5);
bridge.kill();
});
tap.test('streaming: should handle zero chunks (immediate result)', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
readyTimeoutMs: 5000,
});
await bridge.spawn();
const stream = bridge.sendCommandStreaming('streamEmpty', {});
const chunks: any[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
expect(chunks.length).toEqual(0);
const result = await stream.result;
expect(result.totalChunks).toEqual(0);
bridge.kill();
});
tap.test('streaming: should propagate error to iterator and .result', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
readyTimeoutMs: 5000,
requestTimeoutMs: 10000,
});
await bridge.spawn();
const stream = bridge.sendCommandStreaming('streamError', {});
const chunks: any[] = [];
let iteratorError: Error | null = null;
try {
for await (const chunk of stream) {
chunks.push(chunk);
}
} catch (err: any) {
iteratorError = err;
}
// Should have received at least one chunk before error
expect(chunks.length).toEqual(1);
expect(chunks[0].value).toEqual('before_error');
// Iterator should have thrown
expect(iteratorError).toBeTruthy();
expect(iteratorError!.message).toInclude('Stream error after chunk');
// .result should also reject
let resultError: Error | null = null;
try {
await stream.result;
} catch (err: any) {
resultError = err;
}
expect(resultError).toBeTruthy();
expect(resultError!.message).toInclude('Stream error after chunk');
bridge.kill();
});
tap.test('streaming: should fail when bridge is not running', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
});
const stream = bridge.sendCommandStreaming('streamEcho', { count: 3 });
let resultError: Error | null = null;
try {
await stream.result;
} catch (err: any) {
resultError = err;
}
expect(resultError).toBeTruthy();
expect(resultError!.message).toInclude('not running');
});
tap.test('streaming: should fail when killed mid-stream', async () => {
const bridge = new RustBridge<TMockCommands>({
binaryName: 'node',
binaryPath: 'node',
cliArgs: [mockBinaryPath],
readyTimeoutMs: 5000,
requestTimeoutMs: 30000,
});
await bridge.spawn();
// Request many chunks so we can kill mid-stream
const stream = bridge.sendCommandStreaming('streamEcho', { count: 100 });
const chunks: any[] = [];
let iteratorError: Error | null = null;
// Kill after a short delay
setTimeout(() => {
bridge.kill();
}, 50);
try {
for await (const chunk of stream) {
chunks.push(chunk);
}
} catch (err: any) {
iteratorError = err;
}
// Should have gotten some chunks but not all
expect(iteratorError).toBeTruthy();
expect(iteratorError!.message).toInclude('killed');
});
export default tap.start();