Skip to content

http2: ClientHttp2Session can be invalid before close event is emittedΒ #63412

@mcollina

Description

@mcollina

Version

v24.14.1

Platform

Linux matteo-desk 6.17.7-arch1-1 #1 SMP PREEMPT_DYNAMIC Sun, 02 Nov 2025 17:66:99 +0000 x86_64 GNU/Linux

Subsystem

http2

What steps will reproduce the bug?

Run this script with Node.js only, no external dependencies:

'use strict'

const http2 = require('node:http2')

const server = http2.createServer()
let serverSocket

server.on('connection', (socket) => {
  serverSocket = socket
  socket.on('error', () => {})
})

server.on('sessionError', () => {})
server.on('stream', (stream, headers) => {
  if (headers[':path'] === '/close') {
    stream.respond({ ':status': 200 })
    stream.write('partial', () => {
      setImmediate(() => serverSocket.destroy())
    })
    return
  }

  stream.respond({ ':status': 200 })
  stream.end('ok')
})

server.listen(0, () => {
  const session = http2.connect(`http://localhost:${server.address().port}`)

  let clientSessionCloseSeen = false
  let clientSessionErrorSeen = false
  let attempted = false

  session.on('close', () => {
    clientSessionCloseSeen = true
    console.log('client session close event')
    server.close()
  })
  session.on('error', (err) => {
    clientSessionErrorSeen = true
    console.log('client session error event:', err.code)
  })

  function attemptSecondRequest (from) {
    if (attempted) return
    attempted = true
    console.log(`attempting second request from ${from}`)
    console.log('before second request: session.closed=%s session.destroyed=%s closeSeen=%s errorSeen=%s',
      session.closed, session.destroyed, clientSessionCloseSeen, clientSessionErrorSeen)

    try {
      const req2 = session.request({ ':path': '/again' })
      req2.on('error', (err) => console.log('second stream error:', err.code))
      req2.on('response', () => console.log('second stream response'))
      req2.resume()
      console.log('second request did not throw')
    } catch (err) {
      console.log('second request threw:', err.code)
      console.log('after throw: session.closed=%s session.destroyed=%s closeSeen=%s errorSeen=%s',
        session.closed, session.destroyed, clientSessionCloseSeen, clientSessionErrorSeen)
    }
  }

  const req = session.request({ ':path': '/close' })
  req.setEncoding('utf8')
  req.on('response', () => console.log('first stream got response headers'))
  req.on('data', (chunk) => console.log('first stream got data:', chunk))
  req.on('aborted', () => {
    console.log('first stream aborted')
    attemptSecondRequest('first stream aborted')
  })
  req.on('error', (err) => {
    console.log('first stream error:', err.code)
    attemptSecondRequest('first stream error')
  })
  req.on('close', () => {
    console.log('first stream close')
    attemptSecondRequest('first stream close')
  })
})

Output on v24.14.1:

first stream got response headers
first stream got data: partial
first stream close
attempting second request from first stream close
before second request: session.closed=true session.destroyed=true closeSeen=false errorSeen=false
second request threw: ERR_HTTP2_INVALID_SESSION
after throw: session.closed=true session.destroyed=true closeSeen=false errorSeen=false
client session close event

How often does it reproduce? Is there a required condition?

It reproduces consistently for me with the script above.

The condition is an abruptly closed HTTP/2 transport while a client still has a cached ClientHttp2Session and is listening for session lifecycle events to know when to discard it.

What is the expected behavior? Why is that the expected behavior?

I would expect a client that tracks ClientHttp2Session lifecycle through session events to receive a session-level close/error signal before callbacks on an associated stream can observe the session as closed/destroyed and before attempting to create another stream on the cached session throws ERR_HTTP2_INVALID_SESSION.

In other words, once session.closed === true and session.destroyed === true, it would be useful for the session close event to have been emitted already, or for there to be another race-free session-level notification clients can use to invalidate their cached session before stream callbacks run.

What do you see instead?

The first stream emits close while:

  • session.closed === true
  • session.destroyed === true
  • the client session close event has not been emitted yet
  • the client session error event has not been emitted

If code in the stream callback attempts to open another stream on the cached session, session.request() throws synchronously with ERR_HTTP2_INVALID_SESSION before the session close event gives the application a chance to clear the cached session.

Additional information

This surfaced in undici's HTTP/2 client as a race: undici listens to ClientHttp2Session close/end/error/goaway and socket lifecycle events to clear cached connection state, but session.request() can still synchronously throw ERR_HTTP2_INVALID_SESSION before those cleanup handlers run.

Undici can defensively catch this and redispatch requests that were never written, but it would be better if Node's HTTP/2 session lifecycle provided a race-free way to observe that a session is no longer usable before stream callbacks run.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions