feat(rustbridge): add streaming responses and robust large-payload/backpressure handling to RustBridge
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user