console.log(`## TEST ENV ${process.version} ${process.platform} ${process.arch} ##`)
// test for the HTTP `stream.destroyed` change in nodejs v15.5.0
const { createServer, request } = require('http')
const readableStreamToBufferAsync = (readableStream) => new Promise((resolve, reject) => { // the stream is handled
const data = []
readableStream.on('error', reject)
readableStream.on('close', () => reject(new Error('unexpected stream close'))) // for close before end, should already resolved for normal close
readableStream.on('end', () => resolve(Buffer.concat(data)))
readableStream.on('data', (chunk) => data.push(chunk))
})
const setTimeoutAsync = (wait = 0) => new Promise((resolve) => setTimeout(resolve, wait))
const log = (...args) => console.log(...args) // const log = (...args) => console.log(new Date().toISOString(), ...args) // NOTE: for log time to debug
const HOSTNAME = '127.0.0.1'
const PORT = 3000
const PAYLOAD_BUFFER = Buffer.from('test-'.repeat(64))
const testNormalPost = () => new Promise((resolve) => {
log('## testNormalPost ##')
// basic server
const httpServer = createServer()
httpServer.listen(PORT, HOSTNAME)
httpServer.on('request', async (request, response) => {
log('[httpServer] request|response.destroyed', request.destroyed, response.destroyed)
const requestBuffer = await readableStreamToBufferAsync(request)
log('[httpServer] request|response.destroyed read', request.destroyed, response.destroyed)
log('[httpServer] requestBuffer.length', requestBuffer.length)
await setTimeoutAsync(128)
log('[httpServer] request|response.destroyed 128', request.destroyed, response.destroyed)
response.end(requestBuffer)
log('[httpServer] request|response.destroyed end', request.destroyed, response.destroyed)
httpServer.close(() => {
log('[httpServer] request|response.destroyed close', request.destroyed, response.destroyed)
resolve()
})
})
log('[httpServer] created')
// POST request
const httpRequest = request(`http://${HOSTNAME}:${PORT}`, { method: 'POST' })
httpRequest.on('response', async (httpResponse) => {
log('- [httpRequest] httpRequest|httpResponse.destroyed', httpRequest.destroyed, httpResponse.destroyed)
const responseBuffer = await readableStreamToBufferAsync(httpResponse)
log('- [httpRequest] httpRequest|httpResponse.destroyed read', httpRequest.destroyed, httpResponse.destroyed)
log('- [httpRequest] responseBuffer.length', responseBuffer.length)
})
httpRequest.end(PAYLOAD_BUFFER)
log('- [httpRequest] created')
})
const testClientClose = () => new Promise((resolve) => {
log('## testClientClose ##')
// basic server
const httpServer = createServer()
httpServer.listen(PORT, HOSTNAME)
httpServer.on('request', async (request, response) => {
log('[httpServer] request|response.destroyed', request.destroyed, response.destroyed)
const requestBuffer = await readableStreamToBufferAsync(request)
log('[httpServer] request|response.destroyed read', request.destroyed, response.destroyed)
log('[httpServer] requestBuffer.length', requestBuffer.length)
await setTimeoutAsync(128)
log('[httpServer] request|response.destroyed 128', request.destroyed, response.destroyed)
response.end(requestBuffer)
log('[httpServer] request|response.destroyed end', request.destroyed, response.destroyed)
httpServer.close(() => {
log('[httpServer] request|response.destroyed close', request.destroyed, response.destroyed)
resolve()
})
})
log('[httpServer] created')
// POST request
const httpRequest = request(`http://${HOSTNAME}:${PORT}`, { method: 'POST' })
httpRequest.on('response', async (httpResponse) => {
log('- [SHOULD NOT REACH THIS] [httpRequest] httpRequest|httpResponse.destroyed', httpRequest.destroyed, httpResponse.destroyed)
})
httpRequest.end(PAYLOAD_BUFFER)
httpRequest.on('error', (error) => {
log('- [httpRequest] httpRequest.destroyed error', httpRequest.destroyed)
log('- [httpRequest] error', error.message)
})
setTimeoutAsync(64).then(() => {
log('- [httpRequest] httpRequest.destroy!')
httpRequest.destroy()
log('- [httpRequest] httpRequest.destroyed destroy', httpRequest.destroyed)
})
log('- [httpRequest] created')
})
const testServerClose = () => new Promise((resolve) => {
log('## testServerClose ##')
// basic server
const httpServer = createServer()
httpServer.listen(PORT, HOSTNAME)
httpServer.on('request', async (request, response) => {
log('[httpServer] request.destroy!')
request.destroy()
log('[httpServer] request|response.destroyed destroy', request.destroyed, response.destroyed)
httpServer.close(() => {
log('[httpServer] request|response.destroyed close', request.destroyed, response.destroyed)
resolve()
})
})
log('[httpServer] created')
// POST request
const httpRequest = request(`http://${HOSTNAME}:${PORT}`, { method: 'POST' })
httpRequest.on('response', async (httpResponse) => {
log('- [SHOULD NOT REACH THIS] [httpRequest] httpRequest|httpResponse.destroyed', httpRequest.destroyed, httpResponse.destroyed)
})
httpRequest.end(PAYLOAD_BUFFER)
httpRequest.on('error', (error) => {
log('- [httpRequest] httpRequest.destroyed error', httpRequest.destroyed)
log('- [httpRequest] error', error.message)
})
log('- [httpRequest] created')
})
const wait32 = () => setTimeoutAsync(32)
Promise.resolve()
.then(testNormalPost).then(wait32)
.then(testClientClose).then(wait32)
.then(testServerClose).then(wait32)
// repeat
.then(testNormalPost).then(wait32)
.then(testClientClose).then(wait32)
.then(testServerClose).then(wait32)
What steps will reproduce the bug?
Can test with the following script: (added two more test on closing)
[test-nodejs-v15.5.0-http-stream-destroyed.js]
The GitHub Action run for 12/14/15.4/15.5 with linux/win32/darwin: https://github.com/dr-js/dr-js/actions/runs/442120897 (should last for 60 days)
The run output: test-output.zip
The main difference between 15.4 and 15.5 is: (used linux output)
How often does it reproduce? Is there a required condition?
Should always reproduce in v15.5.0.
What is the expected behavior?
No behavior change in minor release.
What do you see instead?
The issue is in Nodejs v15.5.0, the
http.IncomingMessage.destroyedor therequest.destroyedin serverresponse, is settrueas soon as the request payload is retrieved.In earlier versions, the change to
truewill not be sooner thanresponse.destroyed.Additional information
The new behavior makes sense in some way.
But when thinking the
requestandresponseas wrapper of the same underlyingsocket, I think both should bedestroyed(or not) at the same time.And
response.destroyedisundefinedin Nodejs v12, so onlyrequest.destroyedis checkable.For my usage, I check the
request.destroyedin serverresponseto see if the socket is still "alive", and this behavior change make the check pass and main logic/result get skipped.