Description
- Version: v15.5.0
- Platform: linux/win32/darwin
- Subsystem: http
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]
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)
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)
diff --git a/test-output/linux-15.4.txt b/test-output/linux-15.5.txt
index f12d3bf..4f0e70a 100644
--- a/test-output/linux-15.4.txt
+++ b/test-output/linux-15.5.txt
@@ -1,21 +1,21 @@
-## TEST ENV v15.4.0 linux x64 ##
+## TEST ENV v15.5.0 linux x64 ##
## testNormalPost ##
[httpServer] created
- [httpRequest] created
[httpServer] request|response.destroyed false false
-[httpServer] request|response.destroyed read false false
+[httpServer] request|response.destroyed read true false
[httpServer] requestBuffer.length 320
-[httpServer] request|response.destroyed 128 false false
-[httpServer] request|response.destroyed end false false
+[httpServer] request|response.destroyed 128 true false
+[httpServer] request|response.destroyed end true false
- [httpRequest] httpRequest|httpResponse.destroyed false false
-- [httpRequest] httpRequest|httpResponse.destroyed read false false
+- [httpRequest] httpRequest|httpResponse.destroyed read false true
- [httpRequest] responseBuffer.length 320
[httpServer] request|response.destroyed close true true
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.destroyed
or the request.destroyed
in server response
, is set true
as soon as the request payload is retrieved.
In earlier versions, the change to true
will not be sooner than response.destroyed
.
Additional information
The new behavior makes sense in some way.
But when thinking the request
and response
as wrapper of the same underlying socket
, I think both should be destroyed
(or not) at the same time.
And response.destroyed
is undefined
in Nodejs v12, so only request.destroyed
is checkable.
For my usage, I check the request.destroyed
in server response
to see if the socket is still "alive", and this behavior change make the check pass and main logic/result get skipped.