Skip to content

Commit 22c79e1

Browse files
authored
Merge pull request #74 from MatrixAI/feature-message-skip
Fixing random CI failures due to message skips
2 parents 7a50a39 + 769d66f commit 22c79e1

File tree

6 files changed

+1641
-1910
lines changed

6 files changed

+1641
-1910
lines changed

src/RPCServer.ts

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -431,9 +431,8 @@ class RPCServer {
431431
yield await handler(inputVal, cancel, meta, ctx);
432432
break;
433433
}
434-
for await (const _ of input) {
435-
// Noop so that stream can close after flushing
436-
}
434+
// Noop so that stream can close after flushing
435+
for await (const _ of input);
437436
};
438437
this.registerDuplexStreamHandler(method, wrapperDuplex, timeout);
439438
}
@@ -498,51 +497,47 @@ class RPCServer {
498497

499498
const prom = (async () => {
500499
const id = await this.idGen();
501-
const headTransformStream = middleware.binaryToJsonMessageStream(
500+
const transformStream = middleware.binaryToJsonHeaderMessageStream(
502501
utils.parseJSONRPCRequest,
503502
);
504503
// Transparent transform used as a point to cancel the input stream from
505504
const passthroughTransform = new TransformStream<
506505
Uint8Array,
507506
Uint8Array
508507
>();
509-
const inputStream = passthroughTransform.readable;
510508
const inputStreamEndProm = rpcStream.readable
511509
.pipeTo(passthroughTransform.writable)
512510
// Ignore any errors here, we only care that it ended
513511
.catch(() => {});
514-
void inputStream
515-
// Allow us to re-use the readable after reading the first message
516-
.pipeTo(headTransformStream.writable, {
517-
preventClose: true,
518-
preventCancel: true,
519-
})
520-
// Ignore any errors here, we only care that it ended
521-
.catch(() => {});
522512
const cleanUp = async (reason: any) => {
523-
await inputStream.cancel(reason);
513+
// Release resources
514+
await transformStream.readable.cancel(reason);
515+
await transformStream.writable.abort(reason);
516+
await passthroughTransform.readable.cancel(reason);
524517
await rpcStream.writable.abort(reason);
525518
await inputStreamEndProm;
519+
// Stop the timer
526520
timer.cancel(cleanupReason);
527521
await timer.catch(() => {});
528522
};
529-
// Read a single empty value to consume the first message
530-
const reader = headTransformStream.readable.getReader();
523+
passthroughTransform.readable
524+
.pipeTo(transformStream.writable)
525+
.catch(() => {});
526+
const reader = transformStream.readable.getReader();
531527
// Allows timing out when waiting for the first message
532528
let headerMessage:
533-
| ReadableStreamDefaultReadResult<JSONRPCRequest>
534-
| undefined
535-
| void;
529+
| ReadableStreamDefaultReadResult<JSONRPCRequest | Uint8Array>
530+
| undefined;
536531
try {
537532
headerMessage = await Promise.race([
538533
reader.read(),
539534
timer.then(
540535
() => undefined,
541-
() => {},
536+
() => undefined,
542537
),
543538
]);
544539
} catch (e) {
545-
const newErr = new errors.ErrorRPCHandlerFailed(
540+
const err = new errors.ErrorRPCHandlerFailed(
546541
'Stream failed waiting for header',
547542
{ cause: e },
548543
);
@@ -553,49 +548,61 @@ class RPCServer {
553548
new events.RPCErrorEvent({
554549
detail: new errors.ErrorRPCOutputStreamError(
555550
'Stream failed waiting for header',
556-
{ cause: newErr },
551+
{ cause: err },
557552
),
558553
}),
559554
);
560555
return;
561556
}
562557
// Downgrade back to the raw stream
563-
await reader.cancel();
558+
reader.releaseLock();
564559
// There are 2 conditions where we just end here
565560
// 1. The timeout timer resolves before the first message
566561
// 2. the stream ends before the first message
567562
if (headerMessage == null) {
568-
const newErr = new errors.ErrorRPCTimedOut(
563+
const err = new errors.ErrorRPCTimedOut(
569564
'Timed out waiting for header',
570565
{ cause: new errors.ErrorRPCStreamEnded() },
571566
);
572-
await cleanUp(newErr);
567+
await cleanUp(err);
573568
this.dispatchEvent(
574569
new events.RPCErrorEvent({
575570
detail: new errors.ErrorRPCTimedOut(
576571
'Timed out waiting for header',
577572
{
578-
cause: newErr,
573+
cause: err,
579574
},
580575
),
581576
}),
582577
);
583578
return;
584579
}
585580
if (headerMessage.done) {
586-
const newErr = new errors.ErrorMissingHeader('Missing header');
587-
await cleanUp(newErr);
581+
const err = new errors.ErrorMissingHeader('Missing header');
582+
await cleanUp(err);
588583
this.dispatchEvent(
589584
new events.RPCErrorEvent({
590585
detail: new errors.ErrorRPCOutputStreamError(),
591586
}),
592587
);
593588
return;
594589
}
590+
if (headerMessage.value instanceof Uint8Array) {
591+
const err = new errors.ErrorRPCParse('Invalid message type');
592+
await cleanUp(err);
593+
this.dispatchEvent(
594+
new events.RPCErrorEvent({
595+
detail: new errors.ErrorRPCParse(),
596+
}),
597+
);
598+
return;
599+
}
595600
const method = headerMessage.value.method;
596601
const handler = this.handlerMap.get(method);
597602
if (handler == null) {
598-
await cleanUp(new errors.ErrorRPCHandlerFailed('Missing handler'));
603+
await cleanUp(
604+
new errors.ErrorRPCHandlerFailed(`Missing handler for ${method}`),
605+
);
599606
return;
600607
}
601608
if (abortController.signal.aborted) {
@@ -617,13 +624,17 @@ class RPCServer {
617624
timer.refresh();
618625
}
619626
}
620-
621627
this.logger.info(`Handling stream with method (${method})`);
622628
let handlerResult: [JSONObject | undefined, ReadableStream<Uint8Array>];
623629
const headerWriter = rpcStream.writable.getWriter();
624630
try {
631+
// The as keyword is used here as the middleware will only return the
632+
// first message as a JSONMessage, and others as raw Uint8Arrays.
625633
handlerResult = await handler(
626-
[headerMessage.value, inputStream],
634+
[
635+
headerMessage.value,
636+
transformStream.readable as ReadableStream<Uint8Array>,
637+
],
627638
rpcStream.cancel,
628639
rpcStream.meta,
629640
{ signal: abortController.signal, timer },

src/middleware.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,81 @@ function binaryToJsonMessageStream<T extends JSONRPCMessage>(
6161
});
6262
}
6363

64+
/**
65+
* This function is a factory to create a TransformStream that will
66+
* transform a `Uint8Array` stream to a stream containing the JSON header
67+
* message and the rest of the data in `Uint8Array` format.
68+
* The header message will be validated with the provided messageParser, this
69+
* also infers the type of the stream output.
70+
* @param messageParser - Validates the JSONRPC messages, so you can select for a
71+
* specific type of message
72+
* @param bufferByteLimit - sets the number of bytes buffered before throwing an
73+
* error. This is used to avoid infinitely buffering the input.
74+
*/
75+
function binaryToJsonHeaderMessageStream<T extends JSONRPCMessage>(
76+
messageParser: (message: unknown) => T,
77+
bufferByteLimit: number = 1024 * 1024,
78+
): TransformStream<Uint8Array, T | Uint8Array> {
79+
const parser = new JSONParser({
80+
separator: '',
81+
paths: ['$'],
82+
});
83+
let bytesWritten: number = 0;
84+
let accumulator = Buffer.alloc(0);
85+
let rawStream = false;
86+
let parserEnded = false;
87+
88+
const cleanUp = async () => {
89+
// Avoid potential race conditions by allowing parser to end first
90+
const waitP = utils.promise();
91+
parser.onEnd = () => waitP.resolveP();
92+
parser.end();
93+
await waitP.p;
94+
};
95+
96+
return new TransformStream<Uint8Array, T | Uint8Array>({
97+
flush: async () => {
98+
if (!parserEnded) await cleanUp();
99+
},
100+
start: (controller) => {
101+
parser.onValue = async (value) => {
102+
// Enqueue the regular JSON message
103+
const jsonMessage = messageParser(value.value);
104+
controller.enqueue(jsonMessage);
105+
// Remove the header message from the accumulated data
106+
const headerLength = Buffer.from(
107+
JSON.stringify(jsonMessage),
108+
).byteLength;
109+
accumulator = accumulator.subarray(headerLength);
110+
if (accumulator.length > 0) controller.enqueue(accumulator);
111+
// Set system state
112+
bytesWritten = 0;
113+
rawStream = true;
114+
await cleanUp();
115+
parserEnded = true;
116+
};
117+
},
118+
transform: (chunk, controller) => {
119+
try {
120+
bytesWritten += chunk.byteLength;
121+
if (rawStream) {
122+
// Send raw binary data directly
123+
controller.enqueue(chunk);
124+
} else {
125+
// Prepare the data to be parsed to JSON
126+
accumulator = Buffer.concat([accumulator, chunk]);
127+
parser.write(chunk);
128+
}
129+
} catch (e) {
130+
throw new rpcErrors.ErrorRPCParse(undefined, { cause: e });
131+
}
132+
if (bytesWritten > bufferByteLimit) {
133+
throw new rpcErrors.ErrorRPCMessageLength();
134+
}
135+
},
136+
});
137+
}
138+
64139
/**
65140
* This function is a factory for a TransformStream that will transform
66141
* JsonRPCMessages into the `Uint8Array` form. This is used for the stream
@@ -270,6 +345,7 @@ const defaultClientMiddlewareWrapper = (
270345

271346
export {
272347
binaryToJsonMessageStream,
348+
binaryToJsonHeaderMessageStream,
273349
jsonMessageToBinaryStream,
274350
timeoutMiddlewareClient,
275351
timeoutMiddlewareServer,

0 commit comments

Comments
 (0)