1<?php declare(strict_types=1);
2
3/*
4 * This file is part of the Monolog package.
5 *
6 * (c) Jordi Boggiano <j.boggiano@seld.be>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Monolog\Handler;
13
14use Monolog\Logger;
15
16/**
17 * Stores to any socket - uses fsockopen() or pfsockopen().
18 *
19 * @author Pablo de Leon Belloc <pablolb@gmail.com>
20 * @see    http://php.net/manual/en/function.fsockopen.php
21 *
22 * @phpstan-import-type Record from \Monolog\Logger
23 * @phpstan-import-type FormattedRecord from AbstractProcessingHandler
24 */
25class SocketHandler extends AbstractProcessingHandler
26{
27    /** @var string */
28    private $connectionString;
29    /** @var float */
30    private $connectionTimeout;
31    /** @var resource|null */
32    private $resource;
33    /** @var float */
34    private $timeout;
35    /** @var float */
36    private $writingTimeout;
37    /** @var ?int */
38    private $lastSentBytes = null;
39    /** @var ?int */
40    private $chunkSize;
41    /** @var bool */
42    private $persistent;
43    /** @var ?int */
44    private $errno = null;
45    /** @var ?string */
46    private $errstr = null;
47    /** @var ?float */
48    private $lastWritingAt = null;
49
50    /**
51     * @param string     $connectionString  Socket connection string
52     * @param bool       $persistent        Flag to enable/disable persistent connections
53     * @param float      $timeout           Socket timeout to wait until the request is being aborted
54     * @param float      $writingTimeout    Socket timeout to wait until the request should've been sent/written
55     * @param float|null $connectionTimeout Socket connect timeout to wait until the connection should've been
56     *                                      established
57     * @param int|null   $chunkSize         Sets the chunk size. Only has effect during connection in the writing cycle
58     *
59     * @throws \InvalidArgumentException    If an invalid timeout value (less than 0) is passed.
60     */
61    public function __construct(
62        string $connectionString,
63        $level = Logger::DEBUG,
64        bool $bubble = true,
65        bool $persistent = false,
66        float $timeout = 0.0,
67        float $writingTimeout = 10.0,
68        ?float $connectionTimeout = null,
69        ?int $chunkSize = null
70    ) {
71        parent::__construct($level, $bubble);
72        $this->connectionString = $connectionString;
73
74        if ($connectionTimeout !== null) {
75            $this->validateTimeout($connectionTimeout);
76        }
77
78        $this->connectionTimeout = $connectionTimeout ?? (float) ini_get('default_socket_timeout');
79        $this->persistent = $persistent;
80        $this->validateTimeout($timeout);
81        $this->timeout = $timeout;
82        $this->validateTimeout($writingTimeout);
83        $this->writingTimeout = $writingTimeout;
84        $this->chunkSize = $chunkSize;
85    }
86
87    /**
88     * Connect (if necessary) and write to the socket
89     *
90     * {@inheritDoc}
91     *
92     * @throws \UnexpectedValueException
93     * @throws \RuntimeException
94     */
95    protected function write(array $record): void
96    {
97        $this->connectIfNotConnected();
98        $data = $this->generateDataStream($record);
99        $this->writeToSocket($data);
100    }
101
102    /**
103     * We will not close a PersistentSocket instance so it can be reused in other requests.
104     */
105    public function close(): void
106    {
107        if (!$this->isPersistent()) {
108            $this->closeSocket();
109        }
110    }
111
112    /**
113     * Close socket, if open
114     */
115    public function closeSocket(): void
116    {
117        if (is_resource($this->resource)) {
118            fclose($this->resource);
119            $this->resource = null;
120        }
121    }
122
123    /**
124     * Set socket connection to be persistent. It only has effect before the connection is initiated.
125     */
126    public function setPersistent(bool $persistent): self
127    {
128        $this->persistent = $persistent;
129
130        return $this;
131    }
132
133    /**
134     * Set connection timeout.  Only has effect before we connect.
135     *
136     * @see http://php.net/manual/en/function.fsockopen.php
137     */
138    public function setConnectionTimeout(float $seconds): self
139    {
140        $this->validateTimeout($seconds);
141        $this->connectionTimeout = $seconds;
142
143        return $this;
144    }
145
146    /**
147     * Set write timeout. Only has effect before we connect.
148     *
149     * @see http://php.net/manual/en/function.stream-set-timeout.php
150     */
151    public function setTimeout(float $seconds): self
152    {
153        $this->validateTimeout($seconds);
154        $this->timeout = $seconds;
155
156        return $this;
157    }
158
159    /**
160     * Set writing timeout. Only has effect during connection in the writing cycle.
161     *
162     * @param float $seconds 0 for no timeout
163     */
164    public function setWritingTimeout(float $seconds): self
165    {
166        $this->validateTimeout($seconds);
167        $this->writingTimeout = $seconds;
168
169        return $this;
170    }
171
172    /**
173     * Set chunk size. Only has effect during connection in the writing cycle.
174     */
175    public function setChunkSize(int $bytes): self
176    {
177        $this->chunkSize = $bytes;
178
179        return $this;
180    }
181
182    /**
183     * Get current connection string
184     */
185    public function getConnectionString(): string
186    {
187        return $this->connectionString;
188    }
189
190    /**
191     * Get persistent setting
192     */
193    public function isPersistent(): bool
194    {
195        return $this->persistent;
196    }
197
198    /**
199     * Get current connection timeout setting
200     */
201    public function getConnectionTimeout(): float
202    {
203        return $this->connectionTimeout;
204    }
205
206    /**
207     * Get current in-transfer timeout
208     */
209    public function getTimeout(): float
210    {
211        return $this->timeout;
212    }
213
214    /**
215     * Get current local writing timeout
216     *
217     * @return float
218     */
219    public function getWritingTimeout(): float
220    {
221        return $this->writingTimeout;
222    }
223
224    /**
225     * Get current chunk size
226     */
227    public function getChunkSize(): ?int
228    {
229        return $this->chunkSize;
230    }
231
232    /**
233     * Check to see if the socket is currently available.
234     *
235     * UDP might appear to be connected but might fail when writing.  See http://php.net/fsockopen for details.
236     */
237    public function isConnected(): bool
238    {
239        return is_resource($this->resource)
240            && !feof($this->resource);  // on TCP - other party can close connection.
241    }
242
243    /**
244     * Wrapper to allow mocking
245     *
246     * @return resource|false
247     */
248    protected function pfsockopen()
249    {
250        return @pfsockopen($this->connectionString, -1, $this->errno, $this->errstr, $this->connectionTimeout);
251    }
252
253    /**
254     * Wrapper to allow mocking
255     *
256     * @return resource|false
257     */
258    protected function fsockopen()
259    {
260        return @fsockopen($this->connectionString, -1, $this->errno, $this->errstr, $this->connectionTimeout);
261    }
262
263    /**
264     * Wrapper to allow mocking
265     *
266     * @see http://php.net/manual/en/function.stream-set-timeout.php
267     *
268     * @return bool
269     */
270    protected function streamSetTimeout()
271    {
272        $seconds = floor($this->timeout);
273        $microseconds = round(($this->timeout - $seconds) * 1e6);
274
275        if (!is_resource($this->resource)) {
276            throw new \LogicException('streamSetTimeout called but $this->resource is not a resource');
277        }
278
279        return stream_set_timeout($this->resource, (int) $seconds, (int) $microseconds);
280    }
281
282    /**
283     * Wrapper to allow mocking
284     *
285     * @see http://php.net/manual/en/function.stream-set-chunk-size.php
286     *
287     * @return int|bool
288     */
289    protected function streamSetChunkSize()
290    {
291        if (!is_resource($this->resource)) {
292            throw new \LogicException('streamSetChunkSize called but $this->resource is not a resource');
293        }
294
295        if (null === $this->chunkSize) {
296            throw new \LogicException('streamSetChunkSize called but $this->chunkSize is not set');
297        }
298
299        return stream_set_chunk_size($this->resource, $this->chunkSize);
300    }
301
302    /**
303     * Wrapper to allow mocking
304     *
305     * @return int|bool
306     */
307    protected function fwrite(string $data)
308    {
309        if (!is_resource($this->resource)) {
310            throw new \LogicException('fwrite called but $this->resource is not a resource');
311        }
312
313        return @fwrite($this->resource, $data);
314    }
315
316    /**
317     * Wrapper to allow mocking
318     *
319     * @return mixed[]|bool
320     */
321    protected function streamGetMetadata()
322    {
323        if (!is_resource($this->resource)) {
324            throw new \LogicException('streamGetMetadata called but $this->resource is not a resource');
325        }
326
327        return stream_get_meta_data($this->resource);
328    }
329
330    private function validateTimeout(float $value): void
331    {
332        if ($value < 0) {
333            throw new \InvalidArgumentException("Timeout must be 0 or a positive float (got $value)");
334        }
335    }
336
337    private function connectIfNotConnected(): void
338    {
339        if ($this->isConnected()) {
340            return;
341        }
342        $this->connect();
343    }
344
345    /**
346     * @phpstan-param FormattedRecord $record
347     */
348    protected function generateDataStream(array $record): string
349    {
350        return (string) $record['formatted'];
351    }
352
353    /**
354     * @return resource|null
355     */
356    protected function getResource()
357    {
358        return $this->resource;
359    }
360
361    private function connect(): void
362    {
363        $this->createSocketResource();
364        $this->setSocketTimeout();
365        $this->setStreamChunkSize();
366    }
367
368    private function createSocketResource(): void
369    {
370        if ($this->isPersistent()) {
371            $resource = $this->pfsockopen();
372        } else {
373            $resource = $this->fsockopen();
374        }
375        if (is_bool($resource)) {
376            throw new \UnexpectedValueException("Failed connecting to $this->connectionString ($this->errno: $this->errstr)");
377        }
378        $this->resource = $resource;
379    }
380
381    private function setSocketTimeout(): void
382    {
383        if (!$this->streamSetTimeout()) {
384            throw new \UnexpectedValueException("Failed setting timeout with stream_set_timeout()");
385        }
386    }
387
388    private function setStreamChunkSize(): void
389    {
390        if ($this->chunkSize && !$this->streamSetChunkSize()) {
391            throw new \UnexpectedValueException("Failed setting chunk size with stream_set_chunk_size()");
392        }
393    }
394
395    private function writeToSocket(string $data): void
396    {
397        $length = strlen($data);
398        $sent = 0;
399        $this->lastSentBytes = $sent;
400        while ($this->isConnected() && $sent < $length) {
401            if (0 == $sent) {
402                $chunk = $this->fwrite($data);
403            } else {
404                $chunk = $this->fwrite(substr($data, $sent));
405            }
406            if ($chunk === false) {
407                throw new \RuntimeException("Could not write to socket");
408            }
409            $sent += $chunk;
410            $socketInfo = $this->streamGetMetadata();
411            if (is_array($socketInfo) && $socketInfo['timed_out']) {
412                throw new \RuntimeException("Write timed-out");
413            }
414
415            if ($this->writingIsTimedOut($sent)) {
416                throw new \RuntimeException("Write timed-out, no data sent for `{$this->writingTimeout}` seconds, probably we got disconnected (sent $sent of $length)");
417            }
418        }
419        if (!$this->isConnected() && $sent < $length) {
420            throw new \RuntimeException("End-of-file reached, probably we got disconnected (sent $sent of $length)");
421        }
422    }
423
424    private function writingIsTimedOut(int $sent): bool
425    {
426        // convert to ms
427        if (0.0 == $this->writingTimeout) {
428            return false;
429        }
430
431        if ($sent !== $this->lastSentBytes) {
432            $this->lastWritingAt = microtime(true);
433            $this->lastSentBytes = $sent;
434
435            return false;
436        } else {
437            usleep(100);
438        }
439
440        if ((microtime(true) - $this->lastWritingAt) >= $this->writingTimeout) {
441            $this->closeSocket();
442
443            return true;
444        }
445
446        return false;
447    }
448}
449