Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.26% |
282 / 287 |
|
93.10% |
27 / 29 |
CRAP | |
0.00% |
0 / 1 |
| AmpConnectionPool | |
98.26% |
282 / 287 |
|
93.10% |
27 / 29 |
122 | |
0.00% |
0 / 1 |
| __construct | |
90.32% |
28 / 31 |
|
0.00% |
0 / 1 |
14.18 | |||
| __destruct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| acquire | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
| release | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
7 | |||
| close | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| tuneFromOptions | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
8 | |||
| advertised | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| admit | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
6 | |||
| reserve | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| makeRoom | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| wait | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
9 | |||
| dispatch | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| giveBack | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| connect | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| vet | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
| isQuiet | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| sweep | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
| evictOldestIdle | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
10 | |||
| occupancy | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
| slotOf | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| watch | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| assertOpen | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| startSweeper | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
4.10 | |||
| sweepIdle | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| stopSweeper | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| endpointCap | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| endpointOf | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| bucketOf | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| tlsToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * SPDX-License-Identifier: EUPL-1.2 |
| 5 | * |
| 6 | * This file is part of icap-flow. |
| 7 | * |
| 8 | * Licensed under the EUPL, Version 1.2 only (the "Licence"); |
| 9 | * you may not use this work except in compliance with the Licence. |
| 10 | * You may obtain a copy of the Licence at: |
| 11 | * |
| 12 | * https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 |
| 13 | * |
| 14 | * Unless required by applicable law or agreed to in writing, software |
| 15 | * distributed under the Licence is distributed on an "AS IS" basis, |
| 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 17 | */ |
| 18 | |
| 19 | declare(strict_types=1); |
| 20 | |
| 21 | namespace Ndrstmr\Icap\Transport; |
| 22 | |
| 23 | use Amp\Cancellation; |
| 24 | use Amp\CancelledException; |
| 25 | use Amp\DeferredCancellation; |
| 26 | use Amp\Socket\ClientTlsContext; |
| 27 | use Amp\Socket\Socket as SocketInterface; |
| 28 | use Closure; |
| 29 | use Ndrstmr\Icap\Config; |
| 30 | use Ndrstmr\Icap\DTO\IcapResponse; |
| 31 | use Ndrstmr\Icap\Exception\IcapConnectionException; |
| 32 | use Ndrstmr\Icap\Exception\IcapMalformedResponseException; |
| 33 | use Ndrstmr\Icap\Exception\IcapTimeoutException; |
| 34 | use Ndrstmr\Icap\HeaderFields; |
| 35 | use Revolt\EventLoop; |
| 36 | use WeakMap; |
| 37 | use WeakReference; |
| 38 | |
| 39 | /** |
| 40 | * In-process keep-alive pool of amphp sockets, with a limit on connections. |
| 41 | * |
| 42 | * **Limits (M04).** `maxConnectionsPerHost` is the most connections the pool has to one endpoint (host and port, |
| 43 | * whatever the TLS context), and `maxConnections` the most it has at all (default: the same number). Connections |
| 44 | * being set up, in use and idle all count, and room is reserved before the connector is asked. A call that finds |
| 45 | * no room fails with {@see IcapConnectionException} unless a queue is configured: `maxWaiters` calls may then wait, |
| 46 | * first come first served, each for at most `waitTimeout` seconds ({@see IcapTimeoutException}); the caller's |
| 47 | * cancellation ends a wait as `Amp\CancelledException`. A wait ends at its deadline on the monotonic clock, also |
| 48 | * when something held up the event loop and the timer has not run: a call whose wait is over is served nothing. |
| 49 | * Only an idle socket gives way for a new connection, never one in use. Without a pool (or with |
| 50 | * {@see NullConnectionPool}) the application limits its own concurrency. |
| 51 | * |
| 52 | * **Ownership (M06).** The pool takes back only sockets it lent out, and for the bucket they were lent for: a socket |
| 53 | * it never lent out, and one given back for another endpoint or TLS context, is closed, never pooled. A socket that |
| 54 | * is given back again while it is idle stays in the pool once and is handed out once; the second release changes |
| 55 | * nothing. What the pool cannot tell is a second release of a socket that has been handed out again meanwhile from |
| 56 | * the release of its new holder (the interface carries no lease): a session guards itself against giving back |
| 57 | * twice. A socket the holder closed itself, or that was collected, gives its room back, and so does one whose |
| 58 | * peer hung up and which its holder read to the end of the stream: it is closed whole. After {@see close()} idle |
| 59 | * sockets are closed, calls that wait fail, a connection that is still being set up is closed when it arrives, and |
| 60 | * sockets in use stay with their holders and are closed when they are given back. |
| 61 | * |
| 62 | * **Idle sockets (M05).** Idle sockets are a LIFO stack per bucket (host, port and TLS context; most recently used |
| 63 | * first). Each records when it became idle on a monotonic clock. Every acquire ages out all of them, in every |
| 64 | * bucket, and drops empty buckets; a timer (unreferenced, running only while something is idle, gone with |
| 65 | * {@see close()} and with the pool) does so when nothing is acquired. The pool does not watch idle |
| 66 | * sockets: when it hands one out it reads it once, and a socket whose peer has closed, that has received bytes |
| 67 | * nobody asked for, or that fails, is closed instead of handed out. That is no guarantee that the peer still |
| 68 | * listens; a request on a socket that died a moment later fails like any other. |
| 69 | * |
| 70 | * Security domains are separate pools; a rotated TLS context is a new context (or a new pool, with the old one |
| 71 | * closed). Certificate files that change under a context in use change nothing for the sockets already open. |
| 72 | */ |
| 73 | final class AmpConnectionPool implements ConnectionPoolInterface |
| 74 | { |
| 75 | /** The idle timer runs at half the idle time, but not more often than this and not less often than that. */ |
| 76 | private const float MIN_SWEEP_INTERVAL = 0.05; |
| 77 | private const float MAX_SWEEP_INTERVAL = 10.0; |
| 78 | |
| 79 | /** The most endpoints whose Max-Connections the pool keeps; a pool serves a handful of servers, not an open set. */ |
| 80 | private const int MAX_ENDPOINT_LIMITS = 256; |
| 81 | |
| 82 | /** @var array<string, list<array{socket: SocketInterface, idleSince: float}>> idle sockets per bucket, oldest first */ |
| 83 | private array $idle = []; |
| 84 | |
| 85 | /** @var WeakMap<SocketInterface, PoolSlot> every open socket the pool lent out or holds idle */ |
| 86 | private WeakMap $slots; |
| 87 | |
| 88 | /** @var array<int, PoolSlot> connections being set up: room is reserved, no socket yet */ |
| 89 | private array $connecting = []; |
| 90 | |
| 91 | /** @var array<int, PoolWaiter> calls that wait for room, oldest first */ |
| 92 | private array $waiters = []; |
| 93 | |
| 94 | private int $lastWaiter = 0; |
| 95 | |
| 96 | private bool $closed = false; |
| 97 | |
| 98 | private ?string $sweeper = null; |
| 99 | |
| 100 | /** @var WeakMap<ClientTlsContext, int> */ |
| 101 | private WeakMap $tlsTokens; |
| 102 | |
| 103 | private int $lastTlsToken = 0; |
| 104 | |
| 105 | private int $maxConnections; |
| 106 | |
| 107 | private float $waitTimeout; |
| 108 | |
| 109 | private float $sweepInterval; |
| 110 | |
| 111 | /** @var array<string, int> Max-Connections per endpoint, as advertised */ |
| 112 | private array $serverMaxByEndpoint = []; |
| 113 | |
| 114 | /** @var Closure(Config, ?Cancellation): SocketInterface */ |
| 115 | private Closure $connector; |
| 116 | |
| 117 | /** @var Closure(): float */ |
| 118 | private Closure $clock; |
| 119 | |
| 120 | /** |
| 121 | * @param int $maxConnectionsPerHost cap on the connections (being set up, in use and idle) to one endpoint, host and port, whatever the TLS context |
| 122 | * @param (Closure(Config, ?Cancellation): SocketInterface)|null $connector optional override — production code uses the amphp connector; tests can inject pre-built socket pairs |
| 123 | * @param int|null $serverMaxConnections Max-Connections (RFC 3507 §4.10.2) taken as the limit of every endpoint of the pool; it can only lower `maxConnectionsPerHost`. Per endpoint: {@see tuneFromOptions()} |
| 124 | * @param float $maxIdleSeconds idle sockets older than this are closed (default 30 s) |
| 125 | * @param (Closure(): float)|null $clock clock for idle-age and wait-deadline checks; defaults to a monotonic one (`hrtime`); injectable for deterministic tests |
| 126 | * @param int|null $maxConnections cap on the connections of the whole pool, all endpoints together; default `maxConnectionsPerHost` |
| 127 | * @param int $maxWaiters how many calls may wait for a connection when there is none; 0 (default) is no queue: a call that finds the pool full fails at once |
| 128 | * @param float|null $waitTimeout the longest a call waits, in seconds; required with a queue |
| 129 | */ |
| 130 | public function __construct( |
| 131 | private int $maxConnectionsPerHost = 8, |
| 132 | ?Closure $connector = null, |
| 133 | private ?int $serverMaxConnections = null, |
| 134 | private float $maxIdleSeconds = 30.0, |
| 135 | ?Closure $clock = null, |
| 136 | ?int $maxConnections = null, |
| 137 | private int $maxWaiters = 0, |
| 138 | ?float $waitTimeout = null, |
| 139 | ) { |
| 140 | if ($maxConnectionsPerHost < 1) { |
| 141 | throw new \InvalidArgumentException( |
| 142 | 'maxConnectionsPerHost must be >= 1, got: ' . $maxConnectionsPerHost, |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | if ($serverMaxConnections !== null && $serverMaxConnections < 1) { |
| 147 | throw new \InvalidArgumentException( |
| 148 | 'serverMaxConnections must be >= 1, got: ' . $serverMaxConnections, |
| 149 | ); |
| 150 | } |
| 151 | |
| 152 | if (!is_finite($maxIdleSeconds) || $maxIdleSeconds <= 0.0) { |
| 153 | throw new \InvalidArgumentException( |
| 154 | 'maxIdleSeconds must be a finite number > 0, got: ' . var_export($maxIdleSeconds, true), |
| 155 | ); |
| 156 | } |
| 157 | |
| 158 | if ($maxConnections !== null && $maxConnections < 1) { |
| 159 | throw new \InvalidArgumentException( |
| 160 | 'maxConnections must be >= 1, got: ' . $maxConnections, |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | if ($maxWaiters < 0) { |
| 165 | throw new \InvalidArgumentException( |
| 166 | 'maxWaiters must be >= 0, got: ' . $maxWaiters, |
| 167 | ); |
| 168 | } |
| 169 | |
| 170 | if (($waitTimeout !== null && (!is_finite($waitTimeout) || $waitTimeout <= 0.0)) || ($maxWaiters > 0 && $waitTimeout === null)) { |
| 171 | throw new \InvalidArgumentException( |
| 172 | 'waitTimeout must be a finite number > 0, and is required with maxWaiters > 0, got: ' . var_export($waitTimeout, true), |
| 173 | ); |
| 174 | } |
| 175 | |
| 176 | $this->maxConnections = $maxConnections ?? $maxConnectionsPerHost; |
| 177 | $this->waitTimeout = $waitTimeout ?? 0.0; |
| 178 | $this->sweepInterval = max(self::MIN_SWEEP_INTERVAL, min($maxIdleSeconds / 2, self::MAX_SWEEP_INTERVAL)); |
| 179 | $this->connector = $connector ?? SocketOpener::open(...); |
| 180 | $this->clock = $clock ?? static fn (): float => hrtime(true) / 1e9; |
| 181 | $this->slots = new WeakMap(); |
| 182 | $this->tlsTokens = new WeakMap(); |
| 183 | } |
| 184 | |
| 185 | public function __destruct() |
| 186 | { |
| 187 | $this->stopSweeper(); |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * @throws IcapConnectionException When the pool is closed, no connection can be offered (the limit is reached |
| 192 | * and there is no queue, or the queue is full), or the default connector fails; a connector of your own |
| 193 | * passes what it throws on unchanged |
| 194 | * @throws IcapTimeoutException When a call waited for the whole `waitTimeout` without being served |
| 195 | * @throws CancelledException When the caller's cancellation ended the call |
| 196 | */ |
| 197 | #[\Override] |
| 198 | public function acquire(Config $config, ?Cancellation $cancellation = null): SocketInterface |
| 199 | { |
| 200 | $this->assertOpen(); |
| 201 | // Cancelled before it began: neither the idle sockets nor the connector are asked. |
| 202 | $cancellation?->throwIfRequested(); |
| 203 | |
| 204 | $endpoint = $this->endpointOf($config); |
| 205 | $bucket = $this->bucketOf($config, $endpoint); |
| 206 | |
| 207 | while (true) { |
| 208 | // Calls that wait come first: a call that arrives now does not pass them. |
| 209 | $grant = $this->waiters === [] ? $this->admit($endpoint, $bucket) : null; |
| 210 | $grant ??= $this->wait($endpoint, $bucket, $cancellation); |
| 211 | |
| 212 | if ($grant instanceof SocketInterface) { |
| 213 | $grant = $this->vet($grant, $cancellation); |
| 214 | if ($grant === null) { |
| 215 | // The room it held fell under a cap meanwhile: ask again, as any call would. |
| 216 | continue; |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | return $grant instanceof PoolSlot ? $this->connect($config, $cancellation, $grant) : $grant; |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | #[\Override] |
| 225 | public function release(Config $config, SocketInterface $socket): void |
| 226 | { |
| 227 | $slot = $this->slotOf($socket); |
| 228 | |
| 229 | if ($slot === null) { |
| 230 | // Not a socket the pool is lending out: one it never issued, or one that is closed and forgotten. |
| 231 | $socket->close(); |
| 232 | $this->dispatch(); |
| 233 | |
| 234 | return; |
| 235 | } |
| 236 | |
| 237 | if ($slot->idle) { |
| 238 | // Given back before: it is in the pool once, and stays there once. |
| 239 | return; |
| 240 | } |
| 241 | |
| 242 | if ($this->closed || $socket->isClosed() || $slot->bucket !== $this->bucketOf($config, $this->endpointOf($config))) { |
| 243 | // The pool is closed, or the socket is no use, or it is given back for another endpoint or TLS context |
| 244 | // than it was lent for: another bucket would be served a connection it must not have. |
| 245 | $socket->close(); |
| 246 | $this->dispatch(); |
| 247 | |
| 248 | return; |
| 249 | } |
| 250 | |
| 251 | [, $perEndpoint] = $this->occupancy(); |
| 252 | if (($perEndpoint[$slot->endpoint] ?? 0) > $this->endpointCap($slot->endpoint)) { |
| 253 | // A cap fell below what is in use: what comes back beyond it is closed. |
| 254 | $socket->close(); |
| 255 | $this->dispatch(); |
| 256 | |
| 257 | return; |
| 258 | } |
| 259 | |
| 260 | $slot->idle = true; |
| 261 | $this->idle[$slot->bucket][] = ['socket' => $socket, 'idleSince' => ($this->clock)()]; |
| 262 | $this->startSweeper(); |
| 263 | $this->dispatch(); |
| 264 | } |
| 265 | |
| 266 | #[\Override] |
| 267 | public function close(): void |
| 268 | { |
| 269 | $this->closed = true; |
| 270 | $this->stopSweeper(); |
| 271 | |
| 272 | foreach ($this->idle as $entries) { |
| 273 | foreach ($entries as $entry) { |
| 274 | $entry['socket']->close(); |
| 275 | } |
| 276 | } |
| 277 | $this->idle = []; |
| 278 | |
| 279 | // Calls that wait find the pool closed. Sockets in use stay with their holders. |
| 280 | foreach ($this->waiters as $waiter) { |
| 281 | $waiter->wake(); |
| 282 | } |
| 283 | $this->serverMaxByEndpoint = []; |
| 284 | $this->occupancy(); |
| 285 | } |
| 286 | |
| 287 | /** |
| 288 | * Take the `Max-Connections` header of an OPTIONS response as a limit (RFC 3507 §4.10.2). It can only lower |
| 289 | * what the pool allows itself: the effective cap is the smaller of `maxConnectionsPerHost` and the number the |
| 290 | * server advertised. Idle sockets above the new cap are closed at once; sockets in use run to their end, and |
| 291 | * what comes back beyond the cap is closed. |
| 292 | * |
| 293 | * The pool does not know which service an OPTIONS response belongs to, only the endpoint, and only if told: |
| 294 | * with `$config` the number applies to that endpoint (host and port). Without it the pool cannot tell whose |
| 295 | * limit it is and applies it conservatively to every endpoint it serves. The pool keeps the number of at most |
| 296 | * 256 endpoints; for a further one it refuses, rather than forget a limit it was told, which would be to |
| 297 | * bypass it. A cap that rose serves the calls that wait. |
| 298 | * |
| 299 | * Typical usage after an OPTIONS round-trip: |
| 300 | * |
| 301 | * $response = $client->options('/avscan')->await(); |
| 302 | * $pool->tuneFromOptions($response, $config); |
| 303 | * |
| 304 | * @see IcapResponse |
| 305 | * |
| 306 | * @throws IcapMalformedResponseException When the response carries conflicting `Max-Connections` fields, or a |
| 307 | * value that is not a positive decimal number of at most nine digits |
| 308 | * @throws IcapConnectionException When the pool is closed, or already keeps the limits of as many |
| 309 | * endpoints as it takes and `$config` names another one |
| 310 | */ |
| 311 | public function tuneFromOptions(IcapResponse $response, ?Config $config = null): void |
| 312 | { |
| 313 | $value = HeaderFields::single($response->headers, 'Max-Connections'); |
| 314 | if ($value === null) { |
| 315 | return; |
| 316 | } |
| 317 | $advertised = self::advertised($value); |
| 318 | $this->assertOpen(); |
| 319 | |
| 320 | if ($config === null) { |
| 321 | $this->serverMaxConnections = $advertised; |
| 322 | } else { |
| 323 | $endpoint = $this->endpointOf($config); |
| 324 | if (!isset($this->serverMaxByEndpoint[$endpoint]) && count($this->serverMaxByEndpoint) >= self::MAX_ENDPOINT_LIMITS) { |
| 325 | throw new IcapConnectionException(sprintf( |
| 326 | 'The pool already keeps the Max-Connections of %d endpoints and takes no more: use another pool for other servers.', |
| 327 | self::MAX_ENDPOINT_LIMITS, |
| 328 | )); |
| 329 | } |
| 330 | $this->serverMaxByEndpoint[$endpoint] = $advertised; |
| 331 | } |
| 332 | |
| 333 | // Idle sockets above a cap that fell are closed now; sockets in use keep running and are closed on return. |
| 334 | [, $perEndpoint] = $this->occupancy(); |
| 335 | foreach ($perEndpoint as $endpoint => $count) { |
| 336 | while ($count > $this->endpointCap($endpoint) && $this->evictOldestIdle($endpoint)) { |
| 337 | $count--; |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | // A cap that rose is room for the calls that wait. |
| 342 | $this->dispatch(); |
| 343 | } |
| 344 | |
| 345 | /** |
| 346 | * A positive decimal number of at most nine digits, checked before it is cast: (int) reads '2.5' as 2, |
| 347 | * '2abc' as 2 and '1e3' as 1000, and a longer number is no connection limit anyone means. |
| 348 | * |
| 349 | * @throws IcapMalformedResponseException |
| 350 | */ |
| 351 | private static function advertised(string $value): int |
| 352 | { |
| 353 | if (preg_match('/^[0-9]{1,9}\z/', $value) !== 1 || (int) $value < 1) { |
| 354 | // What the server sent stays out of the message: it would end up in logs and in handlers. |
| 355 | throw new IcapMalformedResponseException('Invalid Max-Connections header in the ICAP response: not a positive decimal number of at most nine digits.'); |
| 356 | } |
| 357 | |
| 358 | return (int) $value; |
| 359 | } |
| 360 | |
| 361 | /** |
| 362 | * One atomic step of admission, without a suspension: an idle socket of the bucket, the most recently used |
| 363 | * first, or reserved room for a new connection, or nothing. |
| 364 | */ |
| 365 | private function admit(string $endpoint, string $bucket): SocketInterface|PoolSlot|null |
| 366 | { |
| 367 | $this->sweep(); |
| 368 | |
| 369 | $entry = isset($this->idle[$bucket]) ? array_pop($this->idle[$bucket]) : null; |
| 370 | if ($entry !== null) { |
| 371 | if ($this->idle[$bucket] === []) { |
| 372 | unset($this->idle[$bucket]); |
| 373 | } |
| 374 | $slot = $this->slotOf($entry['socket']); |
| 375 | if ($slot !== null) { |
| 376 | $slot->idle = false; |
| 377 | } |
| 378 | |
| 379 | return $entry['socket']; |
| 380 | } |
| 381 | |
| 382 | return $this->makeRoom($endpoint) ? $this->reserve($endpoint, $bucket) : null; |
| 383 | } |
| 384 | |
| 385 | /** |
| 386 | * Room for one connection, counted from now until the connection is a socket or has failed. |
| 387 | */ |
| 388 | private function reserve(string $endpoint, string $bucket): PoolSlot |
| 389 | { |
| 390 | $slot = new PoolSlot($endpoint, $bucket); |
| 391 | $this->connecting[spl_object_id($slot)] = $slot; |
| 392 | |
| 393 | return $slot; |
| 394 | } |
| 395 | |
| 396 | /** |
| 397 | * Whether a new connection to the endpoint fits, closing idle sockets (the oldest first) if that is what |
| 398 | * stands in the way. A socket in use is never closed to make room: its scan is still running. |
| 399 | */ |
| 400 | private function makeRoom(string $endpoint): bool |
| 401 | { |
| 402 | [$total, $perEndpoint] = $this->occupancy(); |
| 403 | $here = $perEndpoint[$endpoint] ?? 0; |
| 404 | |
| 405 | while ($here >= $this->endpointCap($endpoint)) { |
| 406 | if (!$this->evictOldestIdle($endpoint)) { |
| 407 | return false; |
| 408 | } |
| 409 | $here--; |
| 410 | $total--; |
| 411 | } |
| 412 | |
| 413 | while ($total >= $this->maxConnections) { |
| 414 | if (!$this->evictOldestIdle(null)) { |
| 415 | return false; |
| 416 | } |
| 417 | $total--; |
| 418 | } |
| 419 | |
| 420 | return true; |
| 421 | } |
| 422 | |
| 423 | /** |
| 424 | * Waits, first come first served, for room or an idle socket. Returns what the pool granted. |
| 425 | * |
| 426 | * @throws IcapConnectionException No queue, or the queue is full, or the pool was closed |
| 427 | * @throws IcapTimeoutException The wait ran out |
| 428 | * @throws CancelledException The caller gave up |
| 429 | */ |
| 430 | private function wait(string $endpoint, string $bucket, ?Cancellation $cancellation): SocketInterface|PoolSlot |
| 431 | { |
| 432 | if ($this->maxWaiters === 0) { |
| 433 | throw new IcapConnectionException('No connection is available: the limit of the pool is reached, and the pool has no queue.'); |
| 434 | } |
| 435 | if (count($this->waiters) >= $this->maxWaiters) { |
| 436 | throw new IcapConnectionException(sprintf( |
| 437 | 'No connection is available: the limit of the pool is reached, and its queue of %d calls is full.', |
| 438 | $this->maxWaiters, |
| 439 | )); |
| 440 | } |
| 441 | |
| 442 | $waiter = new PoolWaiter($endpoint, $bucket, ($this->clock)() + $this->waitTimeout); |
| 443 | $id = ++$this->lastWaiter; |
| 444 | $this->waiters[$id] = $waiter; |
| 445 | // Timer and subscription are removed on every way out, not left to destructors: an exception that is |
| 446 | // still referenced keeps the arguments of its stack frames alive. |
| 447 | $timer = EventLoop::delay($this->waitTimeout, static function () use ($waiter): void { |
| 448 | $waiter->wake(); |
| 449 | }); |
| 450 | $watch = $cancellation?->subscribe(static function () use ($waiter): void { |
| 451 | $waiter->wake(); |
| 452 | }); |
| 453 | |
| 454 | try { |
| 455 | // This call may be servable although the calls before it are not. |
| 456 | $this->dispatch(); |
| 457 | $waiter->await(); |
| 458 | } finally { |
| 459 | EventLoop::cancel($timer); |
| 460 | if ($cancellation !== null && $watch !== null) { |
| 461 | $cancellation->unsubscribe($watch); |
| 462 | } |
| 463 | unset($this->waiters[$id]); |
| 464 | } |
| 465 | |
| 466 | $grant = $waiter->grant; |
| 467 | |
| 468 | try { |
| 469 | // The caller's cancellation wins over whatever else ended the wait. |
| 470 | $cancellation?->throwIfRequested(); |
| 471 | $this->assertOpen(); |
| 472 | if ($grant === null || ($this->clock)() >= $waiter->deadline) { |
| 473 | // Also what was granted after the deadline, or before it but taken over only after it, because |
| 474 | // the event loop was held up: the wait is over, and the call gets nothing. |
| 475 | throw new IcapTimeoutException(sprintf( |
| 476 | 'No connection became available within the pool wait timeout of %s seconds.', |
| 477 | $this->waitTimeout, |
| 478 | )); |
| 479 | } |
| 480 | } catch (\Throwable $e) { |
| 481 | // Granted in the same moment the wait ended: the room goes back, once. |
| 482 | if ($grant !== null) { |
| 483 | $this->giveBack($grant); |
| 484 | } |
| 485 | |
| 486 | throw $e; |
| 487 | } |
| 488 | |
| 489 | return $grant; |
| 490 | } |
| 491 | |
| 492 | /** |
| 493 | * Hands room to the calls that wait, oldest first, as far as it goes. Called wherever room may have come free. |
| 494 | */ |
| 495 | private function dispatch(): void |
| 496 | { |
| 497 | if ($this->waiters === []) { |
| 498 | return; |
| 499 | } |
| 500 | |
| 501 | $now = ($this->clock)(); |
| 502 | foreach ($this->waiters as $id => $waiter) { |
| 503 | if ($now >= $waiter->deadline) { |
| 504 | // Its wait is over, whether or not its timer has run yet: it finds that out when it wakes. |
| 505 | continue; |
| 506 | } |
| 507 | |
| 508 | $grant = $this->admit($waiter->endpoint, $waiter->bucket); |
| 509 | if ($grant === null) { |
| 510 | continue; |
| 511 | } |
| 512 | $waiter->grant = $grant; |
| 513 | unset($this->waiters[$id]); |
| 514 | $waiter->wake(); |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | /** |
| 519 | * Takes back what a call was granted and will not use: the room, or the idle socket (which is closed). |
| 520 | */ |
| 521 | private function giveBack(SocketInterface|PoolSlot $grant): void |
| 522 | { |
| 523 | if ($grant instanceof PoolSlot) { |
| 524 | unset($this->connecting[spl_object_id($grant)]); |
| 525 | } else { |
| 526 | $grant->close(); |
| 527 | } |
| 528 | |
| 529 | $this->dispatch(); |
| 530 | } |
| 531 | |
| 532 | /** |
| 533 | * Opens the connection the reserved room is for. The room goes back on every way out that is not a socket. |
| 534 | */ |
| 535 | private function connect(Config $config, ?Cancellation $cancellation, PoolSlot $slot): SocketInterface |
| 536 | { |
| 537 | $socket = null; |
| 538 | |
| 539 | try { |
| 540 | $socket = ($this->connector)($config, $cancellation); |
| 541 | |
| 542 | // A connector need not honour the token it was given, and the pool may have been closed while it |
| 543 | // worked: what it hands back after that is not handed out. |
| 544 | $cancellation?->throwIfRequested(); |
| 545 | $this->assertOpen(); |
| 546 | |
| 547 | if ($this->slotOf($socket) !== null && !$socket->isClosed()) { |
| 548 | // A socket the pool has lent out already is its holder's; leave it alone. |
| 549 | $socket = null; |
| 550 | throw new IcapConnectionException('The connector returned a socket that is in use.'); |
| 551 | } |
| 552 | |
| 553 | unset($this->connecting[spl_object_id($slot)]); |
| 554 | $this->slots[$socket] = $slot; |
| 555 | $this->watch($socket); |
| 556 | |
| 557 | return $socket; |
| 558 | } catch (\Throwable $e) { |
| 559 | $socket?->close(); |
| 560 | unset($this->connecting[spl_object_id($slot)]); |
| 561 | $this->dispatch(); |
| 562 | |
| 563 | throw $e; |
| 564 | } |
| 565 | } |
| 566 | |
| 567 | /** |
| 568 | * Checks an idle socket before it is handed out: it is read once, and any byte or the end of the stream |
| 569 | * means it may not be. A socket that is not fit is closed, and its room is used for a new connection, so |
| 570 | * the call that was served does not lose its turn (unless a cap fell below the room it held). |
| 571 | * |
| 572 | * @return SocketInterface|PoolSlot|null the socket; or the room for a new connection; or null |
| 573 | * |
| 574 | * @throws IcapConnectionException|CancelledException When the pool was closed or the caller gave up meanwhile |
| 575 | */ |
| 576 | private function vet(SocketInterface $socket, ?Cancellation $cancellation): SocketInterface|PoolSlot|null |
| 577 | { |
| 578 | try { |
| 579 | $quiet = $this->isQuiet($socket); |
| 580 | $cancellation?->throwIfRequested(); |
| 581 | $this->assertOpen(); |
| 582 | } catch (\Throwable $e) { |
| 583 | $socket->close(); |
| 584 | $this->dispatch(); |
| 585 | |
| 586 | throw $e; |
| 587 | } |
| 588 | |
| 589 | if ($quiet) { |
| 590 | return $socket; |
| 591 | } |
| 592 | |
| 593 | $slot = $this->slotOf($socket); |
| 594 | $socket->close(); |
| 595 | |
| 596 | // The connection that is gone left room for exactly one in its place, unless a cap fell below it meanwhile. |
| 597 | if ($slot === null || !$this->makeRoom($slot->endpoint)) { |
| 598 | $this->dispatch(); |
| 599 | |
| 600 | return null; |
| 601 | } |
| 602 | |
| 603 | return $this->reserve($slot->endpoint, $slot->bucket); |
| 604 | } |
| 605 | |
| 606 | /** |
| 607 | * Nothing may be waiting on an idle connection: a byte is data nobody asked for, the end of the stream is |
| 608 | * the peer going away, an error is an error. The read carries a cancellation that is already requested, so |
| 609 | * it takes what is there and ends in the same turn of the event loop; nothing is left watching the socket, |
| 610 | * and what the next user is sent is the next user's. A closed socket answers with the end of the stream. The |
| 611 | * socket is closed when it is not quiet, so no byte that is taken here is missed. |
| 612 | */ |
| 613 | private function isQuiet(SocketInterface $socket): bool |
| 614 | { |
| 615 | $probe = new DeferredCancellation(); |
| 616 | $probe->cancel(); |
| 617 | |
| 618 | try { |
| 619 | $socket->read($probe->getCancellation()); |
| 620 | |
| 621 | return false; |
| 622 | } catch (CancelledException) { |
| 623 | return true; |
| 624 | } catch (\Throwable) { |
| 625 | return false; |
| 626 | } |
| 627 | } |
| 628 | |
| 629 | /** |
| 630 | * Closes the idle sockets that are closed, or older than `maxIdleSeconds`, in every bucket, and drops the |
| 631 | * buckets that are left empty. |
| 632 | */ |
| 633 | private function sweep(): void |
| 634 | { |
| 635 | $now = ($this->clock)(); |
| 636 | |
| 637 | foreach ($this->idle as $bucket => $entries) { |
| 638 | $kept = []; |
| 639 | foreach ($entries as $entry) { |
| 640 | if ($entry['socket']->isClosed() || ($now - $entry['idleSince']) > $this->maxIdleSeconds) { |
| 641 | $entry['socket']->close(); |
| 642 | continue; |
| 643 | } |
| 644 | $kept[] = $entry; |
| 645 | } |
| 646 | |
| 647 | if ($kept === []) { |
| 648 | unset($this->idle[$bucket]); |
| 649 | } else { |
| 650 | $this->idle[$bucket] = $kept; |
| 651 | } |
| 652 | } |
| 653 | } |
| 654 | |
| 655 | /** |
| 656 | * Closes the oldest idle socket, of the endpoint or of any endpoint, to make room. |
| 657 | */ |
| 658 | private function evictOldestIdle(?string $endpoint): bool |
| 659 | { |
| 660 | $oldest = null; |
| 661 | |
| 662 | foreach ($this->idle as $bucket => $entries) { |
| 663 | $entry = $entries[0] ?? null; |
| 664 | if ($entry === null || ($endpoint !== null && $this->slotOf($entry['socket'])?->endpoint !== $endpoint)) { |
| 665 | continue; |
| 666 | } |
| 667 | if ($oldest === null || $entry['idleSince'] < $oldest[1]) { |
| 668 | $oldest = [$bucket, $entry['idleSince']]; |
| 669 | } |
| 670 | } |
| 671 | |
| 672 | if ($oldest === null) { |
| 673 | return false; |
| 674 | } |
| 675 | |
| 676 | $entry = array_shift($this->idle[$oldest[0]]); |
| 677 | if ($this->idle[$oldest[0]] === []) { |
| 678 | unset($this->idle[$oldest[0]]); |
| 679 | } |
| 680 | if ($entry !== null) { |
| 681 | $entry['socket']->close(); |
| 682 | } |
| 683 | |
| 684 | return true; |
| 685 | } |
| 686 | |
| 687 | /** |
| 688 | * The connections the pool answers for: those being set up, in use and idle, in all and per endpoint. A socket |
| 689 | * that is closed is no connection, however it was closed; one that was collected is not even in the registry. |
| 690 | * |
| 691 | * @return array{int, array<string, int>} |
| 692 | */ |
| 693 | private function occupancy(): array |
| 694 | { |
| 695 | $total = 0; |
| 696 | $perEndpoint = []; |
| 697 | |
| 698 | foreach ($this->connecting as $slot) { |
| 699 | $total++; |
| 700 | $perEndpoint[$slot->endpoint] = ($perEndpoint[$slot->endpoint] ?? 0) + 1; |
| 701 | } |
| 702 | |
| 703 | $closed = []; |
| 704 | foreach ($this->slots as $socket => $slot) { |
| 705 | if ($socket->isClosed()) { |
| 706 | $closed[] = $socket; |
| 707 | continue; |
| 708 | } |
| 709 | $total++; |
| 710 | $perEndpoint[$slot->endpoint] = ($perEndpoint[$slot->endpoint] ?? 0) + 1; |
| 711 | } |
| 712 | foreach ($closed as $socket) { |
| 713 | unset($this->slots[$socket]); |
| 714 | } |
| 715 | |
| 716 | return [$total, $perEndpoint]; |
| 717 | } |
| 718 | |
| 719 | private function slotOf(SocketInterface $socket): ?PoolSlot |
| 720 | { |
| 721 | return $this->slots->offsetExists($socket) ? $this->slots[$socket] : null; |
| 722 | } |
| 723 | |
| 724 | /** |
| 725 | * A socket that its holder closes itself, or that is collected, gives its room back at once (the registry |
| 726 | * does not count it); calls that wait are told when it happens. |
| 727 | * |
| 728 | * The callback is the socket's, and amphp calls it when the reading end closes. That happens when the peer |
| 729 | * hung up and the holder read the end of the stream, and the writing end may be open still: the socket is not |
| 730 | * closed, its room is not free, and when the holder closes it later nothing tells the pool again. A connection |
| 731 | * nobody can read an answer from is no use for ICAP, so it is closed whole here, and its room is free now. |
| 732 | * Neither the pool nor the socket is held by the callback: a collected socket closes itself. |
| 733 | */ |
| 734 | private function watch(SocketInterface $socket): void |
| 735 | { |
| 736 | $pool = WeakReference::create($this); |
| 737 | $watched = WeakReference::create($socket); |
| 738 | $socket->onClose(static function () use ($pool, $watched): void { |
| 739 | $watched->get()?->close(); |
| 740 | $pool->get()?->dispatch(); |
| 741 | }); |
| 742 | } |
| 743 | |
| 744 | private function assertOpen(): void |
| 745 | { |
| 746 | if ($this->closed) { |
| 747 | throw new IcapConnectionException('Pool is closed.'); |
| 748 | } |
| 749 | } |
| 750 | |
| 751 | private function startSweeper(): void |
| 752 | { |
| 753 | if ($this->sweeper !== null || $this->closed) { |
| 754 | return; |
| 755 | } |
| 756 | |
| 757 | // The timer holds the pool weakly and does not keep the event loop running: a pool nobody holds any |
| 758 | // more, or a loop that has nothing else to do, is not kept alive by it. |
| 759 | $pool = WeakReference::create($this); |
| 760 | $this->sweeper = EventLoop::repeat($this->sweepInterval, static function (string $id) use ($pool): void { |
| 761 | $self = $pool->get(); |
| 762 | if ($self === null) { |
| 763 | EventLoop::cancel($id); |
| 764 | |
| 765 | return; |
| 766 | } |
| 767 | $self->sweepIdle(); |
| 768 | }); |
| 769 | EventLoop::unreference($this->sweeper); |
| 770 | } |
| 771 | |
| 772 | private function sweepIdle(): void |
| 773 | { |
| 774 | $this->sweep(); |
| 775 | if ($this->idle === []) { |
| 776 | $this->stopSweeper(); |
| 777 | } |
| 778 | } |
| 779 | |
| 780 | private function stopSweeper(): void |
| 781 | { |
| 782 | if ($this->sweeper !== null) { |
| 783 | EventLoop::cancel($this->sweeper); |
| 784 | $this->sweeper = null; |
| 785 | } |
| 786 | } |
| 787 | |
| 788 | /** |
| 789 | * The cap on the connections to one endpoint: the pool's own, lowered by what servers advertised. |
| 790 | */ |
| 791 | private function endpointCap(string $endpoint): int |
| 792 | { |
| 793 | return min( |
| 794 | $this->maxConnectionsPerHost, |
| 795 | $this->serverMaxConnections ?? PHP_INT_MAX, |
| 796 | $this->serverMaxByEndpoint[$endpoint] ?? PHP_INT_MAX, |
| 797 | ); |
| 798 | } |
| 799 | |
| 800 | /** |
| 801 | * What a connection counts against: host and port, whatever the TLS context. Host names are not case sensitive. |
| 802 | */ |
| 803 | private function endpointOf(Config $config): string |
| 804 | { |
| 805 | return strtolower($config->host) . ':' . $config->port; |
| 806 | } |
| 807 | |
| 808 | /** |
| 809 | * Where an idle socket may be given out: the endpoint and, for TLS, the context it was opened for. |
| 810 | */ |
| 811 | private function bucketOf(Config $config, string $endpoint): string |
| 812 | { |
| 813 | $tls = $config->getTlsContext(); |
| 814 | |
| 815 | return $tls === null ? $endpoint : $endpoint . ':tls:' . $this->tlsToken($tls); |
| 816 | } |
| 817 | |
| 818 | /** |
| 819 | * The pool's own identity for a TLS context: one number per context |
| 820 | * object, handed out once and never again. An object hash would not do: |
| 821 | * it is recycled as soon as the context is collected, and connectTls() |
| 822 | * connects with a clone of a context without peer name, so nothing keeps |
| 823 | * the original alive while its idle socket is. The next context to take |
| 824 | * over the hash would then be served the old socket (B07). |
| 825 | * |
| 826 | * The counter only grows. Past PHP_INT_MAX the increment throws a |
| 827 | * TypeError instead of wrapping, so an identity is never issued twice. |
| 828 | */ |
| 829 | private function tlsToken(ClientTlsContext $tls): int |
| 830 | { |
| 831 | return $this->tlsTokens[$tls] ??= ++$this->lastTlsToken; |
| 832 | } |
| 833 | } |