Unconsumed cloned responses cause hangs in certain cases
This was a crazy rabbit-hole.
TL;DR:
The responses cloned here:
https://github.com/mswjs/interceptors/blob/77141e2f51b85830dca632d5026177639627987c/src/interceptors/fetch/index.ts#L161-L169
Create issues in the event that the body of the original response is cancelled with response.body.cancel(), if those clones are also not consumed. In such cases, the Promise returned by response.body.cancel() never settles (neither resolves nor rejects).
Further details
As best I can tell, this is due to the underlying .tee() call that happens on the ReadableStream that is the Response's body.
Particularly this bit from the docs made me wonder if the .clone() was the cause of our memory leak issues (doesn't look like it)
If only one cloned branch is consumed, then the entire body will be buffered in memory.
The behavior here though is really odd, and seems to indicate an actual bug in the implementation of ReadableStream (I think) in undici. What happens is that the Promise returned by response.body.cancel() never resolves or rejects if that response has previously been cloned, and the body of the clone has not yet been consumed.
[!NOTE] This behavior does not occur when tested in the browser. The
.cancel()call resolves fairly quickly, and I assume the cloned response becomes disconnected from its source and eventually garbage collection.
A simple reproduction can be seen here:
import { setupServer } from 'msw/node';
if (process.env.ENABLE_MSW === 'true') {
const server = setupServer();
server.listen({
onUnhandledRequest: 'bypass',
});
}
async function run() {
const response = await fetch('https://dummyjson.com/posts/1');
await response.body.cancel();
console.log('response body canceled');
}
run()
.then(() => console.log('done'))
.catch((error) => console.error('fail', error));
This program will produce no output at all with ENABLE_MSW=true, as the await response.body.cancel(); never settles. Incidentally it seems as though the underlying async operation is unref-ed as well, since it doesn't keep the event loop from terminating.
In our application, we got around this by patching the following in right after the emitAsync call:
const responseClone = response.clone()
await emitAsync(this.emitter, 'response', {
response: responseClone,
isMockedResponse: false,
request: requestCloneForResponseEvent,
requestId,
})
+
+ if (!responseClone.bodyUsed) {
+ await responseClone.bytes(); // Consume the body if it hasn't been used
+ }
This is a fairly brute-force approach and I'm not sure it would hold up in every scenario. But I thought I'd put it on our radar since in our case it caused server requests to never complete, creating 504 Gateway timeout errors in an application that simply called setupServer(), without registering any handlers.