'use strict'
const assert = require('node:assert')
const { once } = require('node:events')
const { createSecureServer } = require('node:http2')
const { setTimeout: sleep } = require('node:timers/promises')
const { test } = require('node:test')
const pem = require('@metcoder95/https-pem')
const { Client } = require('..')
const { kHTTP2Session } = require('../lib/core/symbols.js')
// Node keeps every live native stream in the session's internal state, and only
// drops the entry once the stream fully closes. A stream that has fired 'end'
// (so undici releases it via completeRequestStream) is still tracked here during
// the window between 'end' and 'close'. The HTTP/2 idle reaper (#5406) can
// destroy such a session with an error in this window, and Node fans that error
// out to every still-tracked stream. If a released stream has no 'error'
// listener the process crashes (#5936).
function sessionStreams (client) {
const session = client[kHTTP2Session]
if (session == null) {
return []
}
for (const sym of Object.getOwnPropertySymbols(session)) {
const state = session[sym]
if (state && typeof state === 'object' && state.streams) {
return [...state.streams.values()]
}
}
return []
}
async function waitFor (predicate, timeout = 1000) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
if (predicate()) {
return true
}
await sleep(0)
}
return predicate()
}
test('Issue #5936 - released streams retain an error sink so the idle reaper cannot crash', async (t) => {
const server = createSecureServer(await pem.generate({ opts: { keySize: 2048 } }))
server.on('error', () => {})
server.on('session', (session) => {
session.on('error', () => {})
})
server.on('stream', (stream) => {
stream.on('error', () => {})
stream.respond({ ':status': 200 })
stream.end('hello')
})
t.after(() => server.close())
await once(server.listen(0), 'listening')
const client = new Client(`https://localhost:${server.address().port}`, {
allowH2: true,
connect: {
rejectUnauthorized: false
}
})
t.after(() => client.close())
const response = await client.request({ path: '/', method: 'GET' })
await response.body.dump()
// 'end' has fired, so undici released the stream and it is still natively
// tracked during the 'end' -> 'close' window. On some platforms this window is
// too short to observe (the stream closes before we can inspect it), in which
// case the teardown race that this issue describes cannot manifest either.
const found = await waitFor(() => sessionStreams(client).length === 1, 50)
if (!found) {
t.skip('idle-teardown window not observable; released stream already closed')
return
}
const stream = sessionStreams(client)[0]
// A released stream must keep a persistent error sink. If it only installed a
// one-shot sink, a first late error would consume it and a second session-level
// error (as the idle reaper emits) would be delivered with no listener, which
// crashes the process.
assert.strictEqual(stream.listenerCount('error'), 1)
// Simulate the first late error (e.g. a late RST_STREAM / frame error) that
// arrives after undici has finished with the request.
stream.emit('error', new Error('late stream error'))
assert.strictEqual(stream.listenerCount('error'), 1)
// Tear down the session with an error exactly like onHttp2SessionIdleTimeout:
// Node fans the error out to every still-tracked stream. With the persistent
// sink the process keeps running; a one-shot sink would already be consumed
// and trigger an unhandled 'error'.
client[kHTTP2Session].destroy(new Error('socket idle timeout'))
// The session error is delivered asynchronously; give the stream a chance to
// be destroyed with it before asserting the process survived.
await sleep(50)
})