• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

voku / httpful / 25187974306

30 Apr 2026 08:34PM UTC coverage: 90.483% (+0.6%) from 89.902%
25187974306

push

github

web-flow
Merge pull request #26 from voku/php8

[+]: PHP 8.0+ rework

368 of 407 new or added lines in 7 files covered. (90.42%)

1 existing line in 1 file now uncovered.

2548 of 2816 relevant lines covered (90.48%)

49.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.53
/src/Httpful/Curl/MultiCurl.php
1
<?php declare(strict_types=1);
2

3
namespace Httpful\Curl;
4

5
/**
6
 * @internal
7
 */
8
final class MultiCurl
9
{
10
    private ?\CurlMultiHandle $multiCurl = null;
11

12
    /**
13
     * @var Curl[]
14
     */
15
    private $curls = [];
16

17
    /**
18
     * @var Curl[]
19
     */
20
    private $activeCurls = [];
21

22
    /**
23
     * @var bool
24
     */
25
    private $isStarted = false;
26

27
    /**
28
     * @var int
29
     */
30
    private $concurrency = 25;
31

32
    /**
33
     * @var int
34
     */
35
    private $nextCurlId = 0;
36

37
    /**
38
     * @var callable|null
39
     */
40
    private $beforeSendCallback;
41

42
    /**
43
     * @var callable|null
44
     */
45
    private $successCallback;
46

47
    /**
48
     * @var callable|null
49
     */
50
    private $errorCallback;
51

52
    /**
53
     * @var callable|null
54
     */
55
    private $completeCallback;
56

57
    /**
58
     * @var callable|int
59
     */
60
    private $retry;
61

62
    /**
63
     * @var array<string, mixed>
64
     */
65
    private $cookies = [];
66

67
    public function __construct()
68
    {
69
        $this->multiCurl = \curl_multi_init();
43✔
70
    }
71

72
    public function __destruct()
73
    {
74
        $this->close();
43✔
75
    }
76

77
    /**
78
     * Add a Curl instance to the handle queue.
79
     *
80
     * @param Curl $curl
81
     *
82
     * @return $this
83
     */
84
    public function addCurl(Curl $curl)
85
    {
86
        $this->queueHandle($curl);
25✔
87

88
        return $this;
25✔
89
    }
90

91
    /**
92
     * @param callable $callback
93
     *
94
     * @return $this
95
     */
96
    public function beforeSend($callback)
97
    {
98
        $this->beforeSendCallback = $callback;
1✔
99

100
        return $this;
1✔
101
    }
102

103
    /**
104
     * @return void
105
     */
106
    public function close()
107
    {
108
        foreach ($this->curls as $curl) {
43✔
109
            $curl->close();
21✔
110
        }
111

112
        if ($this->multiCurl !== null) {
43✔
113
            \curl_multi_close($this->multiCurl);
43✔
114
            $this->multiCurl = null;
43✔
115
        }
116
    }
117

118
    /**
119
     * @param callable $callback
120
     *
121
     * @return $this
122
     */
123
    public function complete($callback)
124
    {
125
        $this->completeCallback = $callback;
7✔
126

127
        return $this;
7✔
128
    }
129

130
    /**
131
     * @param callable $callback
132
     *
133
     * @return $this
134
     */
135
    public function error($callback)
136
    {
137
        $this->errorCallback = $callback;
2✔
138

139
        return $this;
2✔
140
    }
141

142
    /**
143
     * @param Curl            $curl
144
     * @param callable|string $mixed_filename
145
     *
146
     * @return Curl
147
     */
148
    public function addDownload(Curl $curl, $mixed_filename)
149
    {
150
        $this->queueHandle($curl);
2✔
151

152
        // Use tmpfile() or php://temp to avoid "Too many open files" error.
153
        if (\is_callable($mixed_filename)) {
2✔
154
            $curl->downloadCompleteCallback = $mixed_filename;
1✔
155
            $curl->downloadFileName = null;
1✔
156
            $curl->fileHandle = \tmpfile();
1✔
157
        } else {
158
            $filename = $mixed_filename;
1✔
159

160
            // Use a temporary file when downloading. Not using a temporary file can cause an error when an existing
161
            // file has already fully completed downloading and a new download is started with the same destination save
162
            // path. The download request will include header "Range: bytes=$filesize-" which is syntactically valid,
163
            // but unsatisfiable.
164
            $download_filename = $filename . '.pccdownload';
1✔
165
            $curl->downloadFileName = $download_filename;
1✔
166

167
            // Attempt to resume download only when a temporary download file exists and is not empty.
168
            if (\is_file($download_filename) && $filesize = \filesize($download_filename)) {
1✔
169
                $first_byte_position = $filesize;
×
170
                $range = $first_byte_position . '-';
×
171
                $curl->setRange($range);
×
172
                $curl->fileHandle = \fopen($download_filename, 'ab');
×
173

174
                // Move the downloaded temporary file to the destination save path.
175
                $curl->downloadCompleteCallback = static function ($instance, $fh) use ($download_filename, $filename) {
×
176
                    // Close the open file handle before renaming the file.
177
                    if (\is_resource($fh)) {
×
178
                        \fclose($fh);
×
179
                    }
180

181
                    \rename($download_filename, $filename);
×
182
                };
×
183
            } else {
184
                $curl->fileHandle = \fopen('php://temp', 'wb');
1✔
185
                $curl->downloadCompleteCallback = static function ($instance, $fh) use ($filename) {
1✔
186
                    \file_put_contents($filename, \stream_get_contents($fh));
×
187
                };
1✔
188
            }
189
        }
190

191
        if ($curl->fileHandle === false) {
2✔
192
            throw new \Httpful\Exception\ClientErrorException('Unable to write to file:' . $curl->downloadFileName);
×
193
        }
194

195
        $curl->setFile($curl->fileHandle);
2✔
196
        $curl->setOpt(\CURLOPT_CUSTOMREQUEST, 'GET');
2✔
197
        $curl->setOpt(\CURLOPT_HTTPGET, true);
2✔
198

199
        return $curl;
2✔
200
    }
201

202
    /**
203
     * @param int $concurrency
204
     *
205
     * @return $this
206
     */
207
    public function setConcurrency($concurrency)
208
    {
209
        $this->concurrency = $concurrency;
1✔
210

211
        return $this;
1✔
212
    }
213

214
    /**
215
     * @param string $key
216
     * @param mixed  $value
217
     *
218
     * @return $this
219
     */
220
    public function setCookie($key, $value)
221
    {
222
        $this->cookies[$key] = $value;
1✔
223

224
        return $this;
1✔
225
    }
226

227
    /**
228
     * @param array<string, mixed> $cookies
229
     *
230
     * @return $this
231
     */
232
    public function setCookies($cookies)
233
    {
234
        foreach ($cookies as $key => $value) {
1✔
235
            $this->cookies[$key] = $value;
1✔
236
        }
237

238
        return $this;
1✔
239
    }
240

241
    /**
242
     * Number of retries to attempt or decider callable.
243
     *
244
     * When using a number of retries to attempt, the maximum number of attempts
245
     * for the request is $maximum_number_of_retries + 1.
246
     *
247
     * When using a callable decider, the request will be retried until the
248
     * function returns a value which evaluates to false.
249
     *
250
     * @param callable|int $mixed
251
     *
252
     * @return $this
253
     */
254
    public function setRetry($mixed)
255
    {
256
        $this->retry = $mixed;
1✔
257

258
        return $this;
1✔
259
    }
260

261
    /**
262
     * @return $this|null
263
     */
264
    public function start()
265
    {
266
        if ($this->isStarted) {
10✔
267
            return null;
×
268
        }
269

270
        $this->isStarted = true;
10✔
271

272
        $concurrency = $this->concurrency;
10✔
273
        if ($concurrency > \count($this->curls)) {
10✔
274
            $concurrency = \count($this->curls);
10✔
275
        }
276

277
        for ($i = 0; $i < $concurrency; ++$i) {
10✔
278
            $curl = \array_shift($this->curls);
6✔
279
            if ($curl === null) {
6✔
NEW
280
                break;
×
281
            }
282

283
            $this->initHandle($curl);
6✔
284
        }
285

286
        $multiCurl = $this->getMultiCurlHandle();
10✔
287
        $active = null;
10✔
288
        do {
289
            // Wait for activity on any curl_multi connection when curl_multi_select (libcurl) fails to correctly block.
290
            // https://bugs.php.net/bug.php?id=63411
291
            if ($active && \curl_multi_select($multiCurl) === -1) {
10✔
292
                \usleep(250);
×
293
            }
294

295
            \curl_multi_exec($multiCurl, $active);
10✔
296

297
            while (!(($info_array = \curl_multi_info_read($multiCurl)) === false)) {
10✔
298
                if ($info_array['msg'] === \CURLMSG_DONE) {
6✔
299
                    foreach ($this->activeCurls as $key => $curl) {
6✔
300
                        $curlRes = $curl->getCurl();
6✔
301
                        if ($curlRes === false) {
6✔
302
                            continue;
×
303
                        }
304

305
                        if ($curlRes === $info_array['handle']) {
6✔
306
                            // Set the error code for multi handles using the "result" key in the array returned by
307
                            // curl_multi_info_read(). Using curl_errno() on a multi handle will incorrectly return 0
308
                            // for errors.
309
                            $curl->curlErrorCode = $info_array['result'];
6✔
310
                            $curl->exec($curlRes);
6✔
311

312
                            if ($curl->attemptRetry()) {
6✔
313
                                // Remove completed handle before adding again in order to retry request.
NEW
314
                                \curl_multi_remove_handle($multiCurl, $curlRes);
×
315

NEW
316
                                $curlm_error_code = \curl_multi_add_handle($multiCurl, $curlRes);
×
317
                                if ($curlm_error_code !== \CURLM_OK) {
×
318
                                    throw new \ErrorException(
×
319
                                        'cURL multi add handle error: ' . \curl_multi_strerror($curlm_error_code)
×
320
                                    );
×
321
                                }
322
                            } else {
323
                                $curl->execDone();
6✔
324

325
                                // Remove completed instance from active curls.
326
                                unset($this->activeCurls[$key]);
6✔
327

328
                                // Start new requests before removing the handle of the completed one.
329
                                while (\count($this->curls) >= 1 && \count($this->activeCurls) < $this->concurrency) {
6✔
NEW
330
                                    $this->initHandle(\array_shift($this->curls));
×
331
                                }
332
                                \curl_multi_remove_handle($multiCurl, $curlRes);
6✔
333

334
                                // Clean up completed instance.
335
                                $curl->close();
6✔
336
                            }
337

338
                            break;
6✔
339
                        }
340
                    }
341
                }
342
            }
343

344
            if (!$active) {
10✔
345
                $active = \count($this->activeCurls);
10✔
346
            }
347
        } while ($active > 0);
10✔
348

349
        $this->isStarted = false;
10✔
350

351
        return $this;
10✔
352
    }
353

354
    /**
355
     * @param callable $callback
356
     *
357
     * @return $this
358
     */
359
    public function success($callback)
360
    {
361
        $this->successCallback = $callback;
6✔
362

363
        return $this;
6✔
364
    }
365

366
    /**
367
     * @return \CurlMultiHandle|null
368
     */
369
    public function getMultiCurl()
370
    {
371
        return $this->multiCurl;
1✔
372
    }
373

374
    /**
375
     * @param Curl $curl
376
     *
377
     * @throws \ErrorException
378
     *
379
     * @return void
380
     */
381
    private function initHandle($curl)
382
    {
383
        // Set callbacks if not already individually set.
384

385
        if ($curl->beforeSendCallback === null) {
6✔
386
            $curl->beforeSend($this->beforeSendCallback);
6✔
387
        }
388

389
        if ($curl->successCallback === null) {
6✔
390
            $curl->success($this->successCallback);
6✔
391
        }
392

393
        if ($curl->errorCallback === null) {
6✔
394
            $curl->error($this->errorCallback);
6✔
395
        }
396

397
        if ($curl->completeCallback === null) {
6✔
398
            $curl->complete($this->completeCallback);
6✔
399
        }
400

401
        $curl->setRetry($this->retry);
6✔
402
        $curl->setCookies($this->cookies);
6✔
403

404
        $curlRes = $curl->getCurl();
6✔
405
        if ($curlRes === false) {
6✔
406
            throw new \ErrorException('cURL multi add handle error from curl: curl === false');
×
407
        }
408

409
        $curlm_error_code = \curl_multi_add_handle($this->getMultiCurlHandle(), $curlRes);
6✔
410
        if ($curlm_error_code !== \CURLM_OK) {
6✔
411
            throw new \ErrorException('cURL multi add handle error: ' . \curl_multi_strerror($curlm_error_code));
×
412
        }
413

414
        $curlId = $curl->getId();
6✔
415
        if ($curlId === null) {
6✔
NEW
416
            throw new \ErrorException('cURL multi add handle error: curl id === null');
×
417
        }
418

419
        $this->activeCurls[$curlId] = $curl;
6✔
420
        $curl->call($curl->beforeSendCallback);
6✔
421
    }
422

423
    /**
424
     * @param Curl $curl
425
     *
426
     * @return void
427
     */
428
    private function queueHandle($curl)
429
    {
430
        // Use sequential ids to allow for ordered post processing.
431
        ++$this->nextCurlId;
27✔
432
        $curl->setId($this->nextCurlId);
27✔
433
        $curl->setChildOfMultiCurl(true);
27✔
434
        $this->curls[$this->nextCurlId] = $curl;
27✔
435
    }
436

437
    private function getMultiCurlHandle(): \CurlMultiHandle
438
    {
439
        if ($this->multiCurl === null) {
10✔
NEW
440
            throw new \LogicException('cURL multi handle is not initialized.');
×
441
        }
442

443
        return $this->multiCurl;
10✔
444
    }
445
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc