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
6 changes: 4 additions & 2 deletions deps/undici/src/lib/dispatcher/balanced-pool.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,16 @@ function defaultFactory (origin, opts) {
}

class BalancedPool extends PoolBase {
constructor (upstreams = [], { factory = defaultFactory, ...opts } = {}) {
constructor (upstreams = [], { factory = defaultFactory, connect, tls, ...opts } = {}) {
if (typeof factory !== 'function') {
throw new InvalidArgumentError('factory must be a function.')
}

super(opts)

this[kOptions] = { ...util.deepClone(opts) }
if (connect && typeof connect !== 'function') connect = { ...connect }
if (tls && typeof tls !== 'function') tls = { ...tls }
this[kOptions] = { ...util.deepClone(opts), connect, tls }
this[kOptions].interceptors = opts.interceptors
? { ...opts.interceptors }
: undefined
Expand Down
16 changes: 12 additions & 4 deletions deps/undici/src/lib/dispatcher/client-h1.js
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,7 @@ function onSocketClose () {

function clearIdleSocketValidation (socket) {
if (socket[kIdleSocketValidationTimeout]) {
clearTimeout(socket[kIdleSocketValidationTimeout])
clearImmediate(socket[kIdleSocketValidationTimeout])
socket[kIdleSocketValidationTimeout] = null
}

Expand All @@ -1021,15 +1021,23 @@ function clearIdleSocketValidation (socket) {

function scheduleIdleSocketValidation (client, socket) {
socket[kIdleSocketValidation] = 1
socket[kIdleSocketValidationTimeout] = setTimeout(() => {
// Yield to the check phase (after poll) so unsolicited bytes / FIN / RST
// already pending on this idle keep-alive socket are processed before the
// next request is written (GHSA-35p6-xmwp-9g52).
//
// setTimeout(0) pays Node's ~1ms timer floor on every sequential reuse
// (#5493). setImmediate avoids that, but an *unref'd* Immediate lets poll
// block for ~500ms when the event loop is otherwise idle (#5600 / #5606).
// A ref'd Immediate both keeps the pending request alive and makes poll
// return immediately — the hybrid those issues asked for.
socket[kIdleSocketValidationTimeout] = setImmediate(() => {
socket[kIdleSocketValidationTimeout] = null
socket[kIdleSocketValidation] = 2

if (client[kSocket] === socket && !socket.destroyed) {
client[kResume]()
}
}, 0)
socket[kIdleSocketValidationTimeout].unref?.()
})
}

/**
Expand Down
84 changes: 70 additions & 14 deletions deps/undici/src/lib/dispatcher/client-h2.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ const {
RequestAbortedError,
SocketError,
InformationalError,
InvalidArgumentError
InvalidArgumentError,
HeadersTimeoutError,
BodyTimeoutError
} = require('../core/errors.js')
const {
kUrl,
Expand All @@ -33,6 +35,7 @@ const {
kHTTPContext,
kClosed,
kBodyTimeout,
kHeadersTimeout,
kEnableConnectProtocol,
kRemoteSettings,
kHTTP2Stream,
Expand Down Expand Up @@ -219,7 +222,11 @@ function resumeH2 (client) {
const socket = client[kSocket]

if (socket?.destroyed === false) {
if (client[kSize] === 0 || client[kMaxConcurrentStreams] === 0) {
// Only let the process exit when there is genuinely nothing outstanding.
// Unreffing because the peer advertised MAX_CONCURRENT_STREAMS = 0 left
// queued requests with nothing holding the event loop open, so the process
// could exit with status 0 while an awaited request never settled.
if (client[kSize] === 0) {
socket.unref()
client[kHTTP2Session].unref()
} else {
Expand Down Expand Up @@ -314,6 +321,36 @@ function onHttp2SessionEnd () {
* @this {import('http2').ClientHttp2Session}
* @param {number} errorCode
*/
// Backport of #5410 and #5569. HTTP/2 multiplexes, so requests complete out of
// order; advancing kRunningIdx blindly retired whichever request happened to
// sit at the head instead of the one that actually finished, which both lost
// requests and left phantom running slots behind.
function completeRequest (client, request, resetPendingIdx = false) {
const queue = client[kQueue]
const runningIdx = client[kRunningIdx]

// In-order completion: clear the request and advance without splicing.
// The client's resume loop compacts cleared slots once the index grows.
if (runningIdx < client[kPendingIdx] && queue[runningIdx] === request) {
queue[runningIdx] = null
client[kRunningIdx] = runningIdx + 1
return
}

const index = queue.indexOf(request, runningIdx)

if (index === -1 || index >= client[kPendingIdx]) {
return
}

queue.splice(index, 1)
client[kPendingIdx]--

if (resetPendingIdx && client[kPendingIdx] < client[kRunningIdx]) {
client[kPendingIdx] = client[kRunningIdx]
}
}

function onHttp2SessionGoAway (errorCode) {
// TODO(mcollina): Verify if GOAWAY implements the spec correctly:
// https://datatracker.ietf.org/doc/html/rfc7540#section-6.8
Expand All @@ -335,7 +372,9 @@ function onHttp2SessionGoAway (errorCode) {
if (client[kRunningIdx] < client[kQueue].length) {
const request = client[kQueue][client[kRunningIdx]]
client[kQueue][client[kRunningIdx]++] = null
util.errorRequest(client, request, err)
if (request != null) {
util.errorRequest(client, request, err)
}
client[kPendingIdx] = client[kRunningIdx]
}

Expand Down Expand Up @@ -368,7 +407,9 @@ function onHttp2SessionClose () {
const requests = client[kQueue].splice(client[kRunningIdx])
for (let i = 0; i < requests.length; i++) {
const request = requests[i]
util.errorRequest(client, request, err)
if (request != null) {
util.errorRequest(client, request, err)
}
}
}
}
Expand Down Expand Up @@ -416,7 +457,10 @@ function shouldSendContentLength (method) {
}

function writeH2 (client, request) {
const requestTimeout = request.bodyTimeout ?? client[kBodyTimeout]
// Time to the response headers, then time between body chunks. Using
// bodyTimeout for both made headersTimeout a no-op over HTTP/2.
const headersTimeout = request.headersTimeout ?? client[kHeadersTimeout]
const bodyTimeout = request.bodyTimeout ?? client[kBodyTimeout]
const session = client[kHTTP2Session]
const { method, path, host, upgrade, expectContinue, signal, protocol, headers: reqHeaders } = request
let { body } = request
Expand Down Expand Up @@ -483,6 +527,7 @@ function writeH2 (client, request) {

// We move the running index to the next request
client[kOnError](err)
completeRequest(client, request)
client[kResume]()
}

Expand Down Expand Up @@ -537,7 +582,7 @@ function writeH2 (client, request) {
request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)

++session[kOpenStreams]
client[kQueue][client[kRunningIdx]++] = null
completeRequest(client, request)
})

stream.on('error', () => {
Expand All @@ -554,7 +599,7 @@ function writeH2 (client, request) {
if (session[kOpenStreams] === 0) session.unref()
})

stream.setTimeout(requestTimeout)
stream.setTimeout(headersTimeout)
return true
}

Expand All @@ -570,13 +615,14 @@ function writeH2 (client, request) {

request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
++session[kOpenStreams]
client[kQueue][client[kRunningIdx]++] = null
completeRequest(client, request)
})
stream.on('error', abort)
stream.once('close', () => {
session[kOpenStreams] -= 1
if (session[kOpenStreams] === 0) session.unref()
})
stream.setTimeout(requestTimeout)
stream.setTimeout(headersTimeout)

return true
}
Expand Down Expand Up @@ -677,7 +723,7 @@ function writeH2 (client, request) {

// Increment counter as we have new streams open
++session[kOpenStreams]
stream.setTimeout(requestTimeout)
stream.setTimeout(headersTimeout)

// Track whether we received a response (headers)
let responseReceived = false
Expand All @@ -686,6 +732,7 @@ function writeH2 (client, request) {
const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers
request.onResponseStarted()
responseReceived = true
stream.setTimeout(bodyTimeout)

// Due to the stream nature, it is possible we face a race condition
// where the stream has been assigned, but the request has been aborted
Expand Down Expand Up @@ -720,14 +767,13 @@ function writeH2 (client, request) {
request.onComplete({})
}

client[kQueue][client[kRunningIdx]++] = null
completeRequest(client, request)
client[kResume]()
} else {
// Stream ended without receiving a response - this is an error
// (e.g., server destroyed the stream before sending headers)
abort(new InformationalError('HTTP/2: stream half-closed (remote)'))
client[kQueue][client[kRunningIdx]++] = null
client[kPendingIdx] = client[kRunningIdx]
completeRequest(client, request, true)
client[kResume]()
}
})
Expand All @@ -738,6 +784,14 @@ function writeH2 (client, request) {
if (session[kOpenStreams] === 0) {
session.unref()
}

// A stream can close without ever emitting 'end' or 'error': a peer's
// RST_STREAM(CANCEL) received before the response is reported by Node as a
// bare 'close', and destroying the stream unenrolls its timeout, so no
// 'timeout' follows either. Nothing else would ever settle this request.
if (!request.aborted && !request.completed) {
abort(new InformationalError('HTTP/2: stream closed before the response was complete'))
}
})

stream.once('error', function (err) {
Expand All @@ -755,7 +809,9 @@ function writeH2 (client, request) {
})

stream.on('timeout', () => {
const err = new InformationalError(`HTTP/2: "stream timeout after ${requestTimeout}"`)
const err = responseReceived
? new BodyTimeoutError(`HTTP/2: "body timeout after ${bodyTimeout}"`)
: new HeadersTimeoutError(`HTTP/2: "headers timeout after ${headersTimeout}"`)
stream.removeAllListeners('data')
session[kOpenStreams] -= 1

Expand Down
8 changes: 6 additions & 2 deletions deps/undici/src/lib/dispatcher/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,9 @@ class Client extends DispatcherBase {
const requests = this[kQueue].splice(this[kPendingIdx])
for (let i = 0; i < requests.length; i++) {
const request = requests[i]
util.errorRequest(this, request, err)
if (request != null) {
util.errorRequest(this, request, err)
}
}

const callback = () => {
Expand Down Expand Up @@ -413,7 +415,9 @@ function onError (client, err) {

for (let i = 0; i < requests.length; i++) {
const request = requests[i]
util.errorRequest(client, request, err)
if (request != null) {
util.errorRequest(client, request, err)
}
}
assert(client[kSize] === 0)
}
Expand Down
26 changes: 21 additions & 5 deletions deps/undici/src/lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,13 @@ class CacheHandler {
}

const cacheControlHeader = resHeaders['cache-control']
const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}

if (revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives)) {
deleteCachedValue(this.#store, this.#cacheKey)
return downstreamOnHeaders()
}

const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode)
if (
!cacheControlHeader &&
Expand All @@ -223,8 +230,7 @@ class CacheHandler {
return downstreamOnHeaders()
}

const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
if (!canCacheResponse(this.#cacheType, this.#cacheKey.method, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) {
deleteCachedValue(this.#store, this.#cacheKey)
}
Expand Down Expand Up @@ -465,20 +471,27 @@ function deleteCachedValueIfNotModified (statusCode, store, cacheKey) {
*/
function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) {
return cacheControlDirectives['no-store'] === true ||
(cacheType === 'shared' && cacheControlDirectives.private === true) ||
(cacheType === 'shared' && (
cacheControlDirectives.private === true ||
Object.hasOwn(resHeaders, 'set-cookie')
)) ||
(resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false)
}

/**
* @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
*
* @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
* @param {string} method
* @param {number} statusCode
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
* @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders]
*/
function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
function canCacheResponse (cacheType, method, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
if (!arrayIncludes(util.safeHTTPMethods, method)) {
return false
}
// Status code must be final and understood.
if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) {
return false
Expand All @@ -499,7 +512,10 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect
return false
}

if (cacheType === 'shared' && cacheControlDirectives.private === true) {
if (cacheType === 'shared' && (
cacheControlDirectives.private === true ||
Object.hasOwn(resHeaders, 'set-cookie')
)) {
return false
}

Expand Down
Loading
Loading