From 079b5b15ba030e2af7240f413a36c1312ae06cba Mon Sep 17 00:00:00 2001 From: John Finnerty <297514060+johnfinnerty-nz@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:46:10 +1200 Subject: [PATCH 1/2] doc: clarify QUIC async write backpressure Signed-off-by: John Finnerty <297514060+johnfinnerty-nz@users.noreply.github.com> --- doc/api/quic.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index 9cbff24bc086..ac93be00a000 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -322,9 +322,14 @@ There are two ways to write data to a stream: * **Writer** — access [`stream.writer`][] to push data incrementally. The writer exposes synchronous methods (`writeSync()`, `writevSync()`, `endSync()`) that return immediately, as well as async equivalents - (`write()`, `writev()`, `end()`) that wait for drain when backpressured. + (`write()`, `writev()`, `end()`). The asynchronous write methods use the + stream/iter strict backpressure policy: when the write buffer is full, they + reject with `ERR_INVALID_STATE` instead of waiting for capacity. Check + `writer.canWrite` before writing and use the stream's `onblocked` callback + to observe when flow control prevents progress, retrying once capacity is + available again. `writeSync()` returns `false` when the write buffer is full; the caller - should wait for drain before retrying. + should also wait for `onblocked` before retrying. These two approaches are mutually exclusive for a given stream. @@ -2436,10 +2441,13 @@ The Writer has the following methods: * `writeSync(chunk)` — Synchronous write. Returns `true` if accepted, `false` if flow-controlled. Data is NOT accepted on `false`. -* `write(chunk[, options])` — Async write with drain wait. `options.signal` - is checked at entry but not observed during the write. +* `write(chunk[, options])` — Async write. Rejects with `ERR_INVALID_STATE` + when the stream is flow-controlled rather than waiting for capacity. + `options.signal` is checked at entry but not observed during the write. * `writevSync(chunks)` — Synchronous vectored write. All-or-nothing. -* `writev(chunks[, options])` — Async vectored write. +* `writev(chunks[, options])` — Async vectored write. Rejects with + `ERR_INVALID_STATE` when the stream is flow-controlled rather than waiting + for capacity. * `endSync()` — Synchronous close. Returns total bytes or `-1`. * `end([options])` — Async close. * `fail(reason)` — Errors the stream (sends `RESET_STREAM` to peer). @@ -2451,7 +2459,8 @@ The Writer has the following methods: See [`stream.destroy()`][] for a full-stream abort that also resets the readable side via `STOP_SENDING`. * `canWrite` — `true` if writes will be accepted, `false` if at capacity, - or `null` if closed/errored. + or `null` if closed/errored. Use this property with `stream.onblocked` to + avoid attempting an asynchronous write while the stream is flow-controlled. The bytes from each `writeSync()` / `writevSync()` / `write()` / `writev()` input chunk are copied into an internal buffer, so the caller's source From f80e18bb12d431c6aa48b3b8b2a22b03d1aebd74 Mon Sep 17 00:00:00 2001 From: John Finnerty <297514060+johnfinnerty-nz@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:48:49 +1200 Subject: [PATCH 2/2] doc: correct QUIC writer drain guidance Distinguish writer capacity from transport blocking and stop retrying when no drain wait is available. Assisted-by: Codex Signed-off-by: John Finnerty <297514060+johnfinnerty-nz@users.noreply.github.com> --- doc/api/quic.md | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index ac93be00a000..b96c7d8d03a7 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -321,15 +321,17 @@ There are two ways to write data to a stream: up front or can be expressed as an iterable. * **Writer** — access [`stream.writer`][] to push data incrementally. The writer exposes synchronous methods (`writeSync()`, `writevSync()`, - `endSync()`) that return immediately, as well as async equivalents - (`write()`, `writev()`, `end()`). The asynchronous write methods use the - stream/iter strict backpressure policy: when the write buffer is full, they - reject with `ERR_INVALID_STATE` instead of waiting for capacity. Check - `writer.canWrite` before writing and use the stream's `onblocked` callback - to observe when flow control prevents progress, retrying once capacity is - available again. + `endSync()`) that return immediately, as well as asynchronous counterparts + (`write()`, `writev()`, `end()`). The asynchronous `write()` and `writev()` + methods use the stream/iter strict backpressure policy: when the write buffer + is full, they reject with `ERR_INVALID_STATE` instead of waiting for capacity. + If a drain is already pending, `end()` waits for it before closing. Check + `writer.canWrite` before writing. To wait for capacity, use `ondrain()` from + `node:stream/iter`, then retry the write. The stream's `onblocked` callback + reports that transport flow control has blocked progress, but does not + signal that writer capacity is available again. `writeSync()` returns `false` when the write buffer is full; the caller - should also wait for `onblocked` before retrying. + should wait with `ondrain()` before retrying. These two approaches are mutually exclusive for a given stream. @@ -2449,7 +2451,8 @@ The Writer has the following methods: `ERR_INVALID_STATE` when the stream is flow-controlled rather than waiting for capacity. * `endSync()` — Synchronous close. Returns total bytes or `-1`. -* `end([options])` — Async close. +* `end([options])` — Async close. If a drain is already pending, waits for it + before closing. * `fail(reason)` — Errors the stream (sends `RESET_STREAM` to peer). When `reason` is a [`QuicError`][], its [`error.errorCode`][] is used as the wire code on the resulting `RESET_STREAM` frame; otherwise @@ -2459,8 +2462,20 @@ The Writer has the following methods: See [`stream.destroy()`][] for a full-stream abort that also resets the readable side via `STOP_SENDING`. * `canWrite` — `true` if writes will be accepted, `false` if at capacity, - or `null` if closed/errored. Use this property with `stream.onblocked` to - avoid attempting an asynchronous write while the stream is flow-controlled. + or `null` if closed/errored. When `writeSync()` returns `false`, use + `ondrain()` from `node:stream/iter` to wait before retrying. If `ondrain()` + returns `null`, no drain wait is available and the write should not be + retried. + +```mjs +import { ondrain } from 'node:stream/iter'; + +while (!writer.writeSync(chunk)) { + const drain = ondrain(writer); + if (drain === null) break; + await drain; +} +``` The bytes from each `writeSync()` / `writevSync()` / `write()` / `writev()` input chunk are copied into an internal buffer, so the caller's source