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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions