Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ eagerly.
> * The server must support HTTP/2 and select it during ALPN negotiation, and
> must not give HTTP/1.1 a higher priority than HTTP/2.
> * `PUSH` frames are not supported.
> * Aborting a request, for example through its `signal`, resets its stream
> with `RST_STREAM` and the `CANCEL` error code, including when part of the
> request body has not been sent yet.

```mjs displayName="Basic instantiation"
import { Client } from 'undici'
Expand Down
8 changes: 6 additions & 2 deletions lib/dispatcher/client-h2.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const {
HTTP2_HEADER_EXPECT,
HTTP2_HEADER_STATUS,
HTTP2_HEADER_PROTOCOL,
NGHTTP2_CANCEL,
NGHTTP2_NO_ERROR,
NGHTTP2_PROTOCOL_ERROR,
NGHTTP2_REFUSED_STREAM
Expand Down Expand Up @@ -1165,9 +1166,12 @@ function writeH2 (client, request) {
if (state.stream != null) {
clearRequestStream(request)

// On Abort, we close the stream to send RST_STREAM frame.
// On Abort, we close the stream to send RST_STREAM frame. Use CANCEL,
// the code for a stream that is no longer needed: with NO_ERROR, Node
// defers the RST_STREAM until a still-queued request body finishes,
// and the synchronous destroy below means that never happens.
const stream = state.stream
stream.close()
stream.close(NGHTTP2_CANCEL)

// close() alone leaves cleanup waiting on the 'close' event; on a busy,
// long-lived multiplexed session that event can fail to fire, leaving the
Expand Down
58 changes: 58 additions & 0 deletions test/http2-abort-rst-cancel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use strict'

const assert = require('node:assert')
const { test, after } = require('node:test')
const { constants, createSecureServer } = require('node:http2')
const { once } = require('node:events')

const pem = require('@metcoder95/https-pem')

const { Client } = require('..')

// Aborting a request resets its stream with RST_STREAM(CANCEL), the code for a
// stream that is no longer needed. With the default NO_ERROR, Node defers the
// RST_STREAM until a still-queued request body has been written, and the
// synchronous destroy() in abort() means that never happens: the peer is never
// told the stream is gone.

test('aborting a request with a queued body resets the stream with CANCEL', async () => {
// A zero initial window keeps the request body queued on the client.
const server = createSecureServer({
...(await pem.generate({ opts: { keySize: 2048 } })),
settings: { initialWindowSize: 0 }
})

const controller = new AbortController()
const reset = new Promise((resolve) => {
server.on('stream', (stream) => {
stream.on('error', () => {})
stream.on('close', () => resolve(stream.rstCode))
controller.abort()
})
})

after(() => server.close())
await once(server.listen(0), 'listening')

const client = new Client(`https://localhost:${server.address().port}`, {
connect: { rejectUnauthorized: false },
allowH2: true
})
after(() => client.destroy())

const request = client.request({
path: '/',
method: 'POST',
body: Buffer.alloc(32 * 1024),
signal: controller.signal
}).then(() => 'resolved', (err) => err.name)

let timer
const rstCode = await Promise.race([
reset,
new Promise((resolve) => { timer = setTimeout(() => resolve('no RST_STREAM'), 5000) })
]).finally(() => clearTimeout(timer))

assert.strictEqual(rstCode, constants.NGHTTP2_CANCEL)
assert.strictEqual(await request, 'AbortError')
})
Loading