Skip to content

[Bug] http.IncomingMessage.destroyed is true after payload read since v15.5.0 #36617

Closed
@dr-js

Description

@dr-js
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    httpIssues or PRs related to the http subsystem.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions