Files
pnpm/pnpm11/network/fetch/test/networkConcurrencyGate.test.ts
cbb2eb04ea fix: lower fetch concurrency per origin when a timeout hits an active download (#15880)
A fetch timeout while another request to the same origin is still in
flight now lowers that origin's in-flight cap to one connection. The
configured network concurrency stays in place until that happens, and
other origins keep it. Remaining retries and later requests to that
origin wait for the single slot.

The cap is per origin, so one slow registry or tarball host cannot drag
down downloads from healthy ones. On a slow link, nearly every download
comes from one registry, so that origin times out against its own peers
and is lowered.

In the Rust client each origin gets a gate next to the maxSockets limit,
acquired before the global semaphore so a request waiting on a lowered
origin holds no global slot. Behind a proxy the origin is the proxy's.
In the TypeScript client each connection origin (again the proxy's when
one applies) gets a gate around its registry fetches, held until the body settles, so a stalled download still counts
as in flight. Already-granted requests finish. Further requests to that
origin wait until one remains.

pnpm/pnpm#12791

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:27:25 +02:00

125 lines
4.1 KiB
TypeScript

/// <reference path="../../../__typings__/index.d.ts"/>
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, expect, test } from '@jest/globals'
import { clearDispatcherCache, createFetchFromRegistry } from '@pnpm/network.fetch'
import { getConnectionOrigin } from '../src/dispatcher.js'
import { createNetworkConcurrencyGate, createOriginConcurrencyGates } from '../src/networkConcurrencyGate.js'
test('a timeout with peers in flight shrinks the cap and holds new acquires', async () => {
const gate = createNetworkConcurrencyGate(3)
await gate.acquire()
await gate.acquire()
await gate.acquire()
expect(gate.downscaleIfPeersActive()).toBe(true)
expect(gate.limit).toBe(1)
expect(gate.downscaleIfPeersActive()).toBe(false)
let granted = false
const waiting = gate.acquire().then(() => {
granted = true
})
gate.release()
gate.release()
await Promise.resolve()
expect(granted).toBe(false)
gate.release()
await waiting
expect(granted).toBe(true)
})
test('a lone in-flight request does not shrink the cap', async () => {
const gate = createNetworkConcurrencyGate(4)
await gate.acquire()
expect(gate.downscaleIfPeersActive()).toBe(false)
expect(gate.limit).toBe(4)
gate.release()
})
test('a downscale on one origin leaves other origins unbounded', async () => {
const gateFor = createOriginConcurrencyGates()
const slow = gateFor('https://slow.example')
expect(gateFor('https://slow.example')).toBe(slow)
await slow.acquire()
await slow.acquire()
expect(slow.downscaleIfPeersActive()).toBe(true)
const other = gateFor('https://fast.example')
await other.acquire()
await other.acquire()
await other.acquire()
expect(other.limit).toBe(Number.POSITIVE_INFINITY)
})
test('requests through one proxy share its origin unless noProxy exempts them', () => {
const opts = { httpsProxy: 'http://user:secret@proxy.example:8080', noProxy: 'direct.example' }
expect(getConnectionOrigin(new URL('https://registry.example/pkg'), opts)).toBe('http://proxy.example:8080')
expect(getConnectionOrigin(new URL('https://cdn.example/pkg.tgz'), opts)).toBe('http://proxy.example:8080')
expect(getConnectionOrigin(new URL('https://direct.example/pkg'), opts)).toBe('https://direct.example')
expect(getConnectionOrigin(new URL('http://plain.example/pkg'), opts)).toBe('http://plain.example')
})
test('distinct SOCKS proxies get distinct connection origins without their credentials', () => {
const uri = new URL('https://registry.example/pkg')
expect(getConnectionOrigin(uri, { httpsProxy: 'socks5://user:secret@one.example:1080' })).toBe('socks5://one.example:1080')
expect(getConnectionOrigin(uri, { httpsProxy: 'socks5://two.example:1080' })).toBe('socks5://two.example:1080')
})
afterEach(() => {
clearDispatcherCache()
})
test('a fetch timeout while another download is active retries at one connection', async () => {
let open = 0
let accepted = 0
const server = createServer((req, res) => {
accepted++
open++
const stall = accepted <= 2
let closed = false
req.on('close', () => {
if (closed) return
closed = true
open--
})
if (stall) return
const timer = setInterval(() => {
if (!closed && open === 1) {
clearInterval(timer)
res.end('ok')
}
}, 10)
req.on('close', () => {
clearInterval(timer)
})
})
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve)
})
const { port } = server.address() as AddressInfo
const url = `http://127.0.0.1:${port}/pkg`
try {
const fetchFromRegistry = createFetchFromRegistry({})
const read = async () => {
const response = await fetchFromRegistry(url, {
retry: { factor: 1, maxTimeout: 20, minTimeout: 1, retries: 4 },
timeout: 200,
})
return response.text()
}
const [left, right] = await Promise.all([read(), read()])
expect(left).toBe('ok')
expect(right).toBe('ok')
} finally {
server.closeAllConnections()
await new Promise<void>((resolve) => {
server.close(() => {
resolve()
})
})
}
}, 20_000)