Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
WaitConditionLoop
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
4 / 4
19
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 invoke
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
9
 getLastWaitTime
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 usleep
n/a
0 / 0
n/a
0 / 0
1
 getWallTime
n/a
0 / 0
n/a
0 / 0
1
 getCpuTime
n/a
0 / 0
n/a
0 / 0
2
 popAndRunBusyCallback
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Wait loop that reaches a condition or times out.
4 *
5 * Copyright (C) 2016 Aaron Schulz <aschulz@wikimedia.org>
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 2 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License along
18 * with this program; if not, write to the Free Software Foundation, Inc.,
19 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
20 * http://www.gnu.org/copyleft/gpl.html
21 *
22 * @file
23 * @author Aaron Schulz
24 */
25
26namespace Wikimedia;
27
28/**
29 * Wait loop that reaches a condition or times out
30 */
31class WaitConditionLoop {
32    /** @var callable */
33    private $condition;
34    /** @var callable[] */
35    private $busyCallbacks = [];
36    /** @var float Seconds */
37    private $timeout;
38    /** @var float Seconds */
39    private $lastWaitTime;
40    /** @var int|null */
41    private $rusageMode;
42
43    public const CONDITION_REACHED = 1;
44    // evaluates as falsey
45    public const CONDITION_CONTINUE = 0;
46    public const CONDITION_FAILED = -1;
47    public const CONDITION_TIMED_OUT = -2;
48    public const CONDITION_ABORTED = -3;
49
50    /**
51     * @param callable $condition Callback that returns a WaitConditionLoop::CONDITION_ constant
52     * @param float $timeout Timeout in seconds
53     * @param array &$busyCallbacks List of callbacks to do useful work (by reference)
54     */
55    public function __construct( callable $condition, $timeout = 5.0, &$busyCallbacks = [] ) {
56        $this->condition = $condition;
57        $this->timeout = $timeout;
58        $this->busyCallbacks =& $busyCallbacks;
59
60        // @codeCoverageIgnoreStart
61        if ( function_exists( 'getrusage' ) ) {
62            // RUSAGE_SELF
63            $this->rusageMode = 0;
64        }
65        // @codeCoverageIgnoreEnd
66    }
67
68    /**
69     * Invoke the loop and continue until either:
70     *   - a) The condition callback returns neither CONDITION_CONTINUE nor false
71     *   - b) The timeout is reached
72     * Thus a condition callback can return true (stop) or false (continue) for convenience.
73     * In such cases, the halting result of "true" will be converted to CONDITION_REACHED.
74     *
75     * If $timeout is 0, then only the condition callback will be called (no busy callbacks),
76     * and this will immediately return CONDITION_FAILED if the condition was not met.
77     *
78     * Exceptions in callbacks will be caught and the callback will be swapped with
79     * one that simply rethrows that exception back to the caller when invoked.
80     *
81     * @return int WaitConditionLoop::CONDITION_* constant
82     * @throws \Exception Any error from the condition callback
83     */
84    public function invoke() {
85        // seconds
86        $elapsed = 0.0;
87        // microseconds to sleep each time
88        $sleepUs = 0;
89        $lastCheck = false;
90        $finalResult = self::CONDITION_TIMED_OUT;
91        do {
92            $checkStartTime = $this->getWallTime();
93            // Check if the condition is met yet
94            $realStart = $this->getWallTime();
95            $cpuStart = $this->getCpuTime();
96            $checkResult = call_user_func( $this->condition );
97            $cpu = $this->getCpuTime() - $cpuStart;
98            $real = $this->getWallTime() - $realStart;
99            // Exit if the condition is reached, an error occurs, or this is non-blocking
100            if ( $this->timeout <= 0 ) {
101                // Accepts boolean true or self::CONDITION_REACHED
102                $finalResult = $checkResult > 0 ? self::CONDITION_REACHED : self::CONDITION_FAILED;
103                break;
104            } elseif ( (int)$checkResult !== self::CONDITION_CONTINUE ) {
105                if ( is_int( $checkResult ) ) {
106                    $finalResult = $checkResult;
107                } else {
108                    $finalResult = self::CONDITION_REACHED;
109                }
110                break;
111            } elseif ( $lastCheck ) {
112                // timeout reached
113                break;
114            }
115            // Detect if condition callback seems to block or if justs burns CPU
116            $conditionUsesInterrupts = ( $real > 0.100 && $cpu <= $real * 0.03 );
117            if ( !$this->popAndRunBusyCallback() && !$conditionUsesInterrupts ) {
118                // 10 queries = 10(10+100)/2 ms = 550ms, 14 queries = 1050ms
119                // stop incrementing at ~1s
120                $sleepUs = min( $sleepUs + 10 * 1e3, 1e6 );
121                $this->usleep( $sleepUs );
122            }
123            $checkEndTime = $this->getWallTime();
124            // The max() protects against the clock getting set back
125            $elapsed += max( $checkEndTime - $checkStartTime, 0.010 );
126            // Do not let slow callbacks timeout without checking the condition one more time
127            $lastCheck = ( $elapsed >= $this->timeout );
128        } while ( true );
129
130        $this->lastWaitTime = $elapsed;
131
132        return $finalResult;
133    }
134
135    /**
136     * @return float Seconds
137     */
138    public function getLastWaitTime() {
139        return $this->lastWaitTime;
140    }
141
142    /**
143     * @param int $microseconds
144     * @codeCoverageIgnore
145     */
146    protected function usleep( $microseconds ) {
147        usleep( $microseconds );
148    }
149
150    /**
151     * @return float
152     * @codeCoverageIgnore
153     */
154    protected function getWallTime() {
155        return microtime( true );
156    }
157
158    /**
159     * @return float Returns 0.0 if not supported (Windows on PHP < 7)
160     * @codeCoverageIgnore
161     */
162    protected function getCpuTime() {
163        if ( $this->rusageMode === null ) {
164            // assume worst case (all time is CPU)
165            return microtime( true );
166        }
167
168        $ru = getrusage( $this->rusageMode );
169        $time = $ru['ru_utime.tv_sec'] + $ru['ru_utime.tv_usec'] / 1e6;
170        $time += $ru['ru_stime.tv_sec'] + $ru['ru_stime.tv_usec'] / 1e6;
171
172        return $time;
173    }
174
175    /**
176     * Run one of the callbacks that does work ahead of time for another caller
177     *
178     * @return bool Whether a callback was executed
179     */
180    private function popAndRunBusyCallback() {
181        if ( $this->busyCallbacks ) {
182            reset( $this->busyCallbacks );
183            $key = key( $this->busyCallbacks );
184            /** @var callable $workCallback */
185            $workCallback =& $this->busyCallbacks[$key];
186            try {
187                $workCallback();
188            } catch ( \Exception $e ) {
189                /** @return never */
190                $workCallback = static function () use ( $e ) {
191                    throw $e;
192                };
193            }
194            // consume
195            unset( $this->busyCallbacks[$key] );
196
197            return true;
198        }
199
200        return false;
201    }
202}