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.
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:
Output on v24.14.1:
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
ClientHttp2Sessionand is listening forsessionlifecycle 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
ClientHttp2Sessionlifecycle through session events to receive a session-level close/error signal before callbacks on an associated stream can observe the session asclosed/destroyedand before attempting to create another stream on the cached session throwsERR_HTTP2_INVALID_SESSION.In other words, once
session.closed === trueandsession.destroyed === true, it would be useful for the sessioncloseevent 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
closewhile:session.closed === truesession.destroyed === truecloseevent has not been emitted yeterrorevent has not been emittedIf code in the stream callback attempts to open another stream on the cached session,
session.request()throws synchronously withERR_HTTP2_INVALID_SESSIONbefore the sessioncloseevent 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
ClientHttp2Sessionclose/end/error/goawayand socket lifecycle events to clear cached connection state, butsession.request()can still synchronously throwERR_HTTP2_INVALID_SESSIONbefore 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.