diff --git a/src/Illuminate/Cache/Lock.php b/src/Illuminate/Cache/Lock.php index bed170507a9a..ccd1c6474a5f 100644 --- a/src/Illuminate/Cache/Lock.php +++ b/src/Illuminate/Cache/Lock.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Cache\Lock as LockContract; use Illuminate\Contracts\Cache\LockTimeoutException; use Illuminate\Support\InteractsWithTime; +use Illuminate\Support\Sleep; use Illuminate\Support\Str; abstract class Lock implements LockContract @@ -114,7 +115,7 @@ public function block($seconds, $callback = null) $starting = $this->currentTime(); while (! $this->acquire()) { - usleep($this->sleepMilliseconds * 1000); + Sleep::usleep($this->sleepMilliseconds * 1000); if ($this->currentTime() - $seconds >= $starting) { throw new LockTimeoutException; diff --git a/src/Illuminate/Foundation/Testing/TestCase.php b/src/Illuminate/Foundation/Testing/TestCase.php index 9fae49f6bc2e..a7315a2c5124 100644 --- a/src/Illuminate/Foundation/Testing/TestCase.php +++ b/src/Illuminate/Foundation/Testing/TestCase.php @@ -10,6 +10,7 @@ use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\ParallelTesting; +use Illuminate\Support\Sleep; use Illuminate\Support\Str; use Illuminate\View\Component; use Mockery; @@ -245,6 +246,7 @@ protected function tearDown(): void Component::forgetFactory(); Queue::createPayloadUsing(null); HandleExceptions::forgetApp(); + Sleep::fake(false); if ($this->callbackException) { throw $this->callbackException; diff --git a/src/Illuminate/Redis/Limiters/ConcurrencyLimiter.php b/src/Illuminate/Redis/Limiters/ConcurrencyLimiter.php index e249053e2cc9..62e50b01aad1 100644 --- a/src/Illuminate/Redis/Limiters/ConcurrencyLimiter.php +++ b/src/Illuminate/Redis/Limiters/ConcurrencyLimiter.php @@ -3,6 +3,7 @@ namespace Illuminate\Redis\Limiters; use Illuminate\Contracts\Redis\LimiterTimeoutException; +use Illuminate\Support\Sleep; use Illuminate\Support\Str; use Throwable; @@ -75,7 +76,7 @@ public function block($timeout, $callback = null, $sleep = 250) throw new LimiterTimeoutException; } - usleep($sleep * 1000); + Sleep::usleep($sleep * 1000); } if (is_callable($callback)) { diff --git a/src/Illuminate/Redis/Limiters/DurationLimiter.php b/src/Illuminate/Redis/Limiters/DurationLimiter.php index 65297ff9f65f..b0ecdaf9f4b4 100644 --- a/src/Illuminate/Redis/Limiters/DurationLimiter.php +++ b/src/Illuminate/Redis/Limiters/DurationLimiter.php @@ -3,6 +3,7 @@ namespace Illuminate\Redis\Limiters; use Illuminate\Contracts\Redis\LimiterTimeoutException; +use Illuminate\Support\Sleep; class DurationLimiter { @@ -84,7 +85,7 @@ public function block($timeout, $callback = null, $sleep = 750) throw new LimiterTimeoutException; } - usleep($sleep * 1000); + Sleep::usleep($sleep * 1000); } if (is_callable($callback)) { diff --git a/src/Illuminate/Support/Sleep.php b/src/Illuminate/Support/Sleep.php new file mode 100644 index 000000000000..d4ee90f261e1 --- /dev/null +++ b/src/Illuminate/Support/Sleep.php @@ -0,0 +1,393 @@ + + */ + protected static $sequence = []; + + /** + * Indicates if the instance should sleep. + * + * @var bool + */ + protected $shouldSleep = true; + + /** + * Create a new class instance. + * + * @param int|float|\DateInterval $duration + * @return void + */ + public function __construct($duration) + { + if (! $duration instanceof DateInterval) { + $this->duration = CarbonInterval::microsecond(0); + + $this->pending = $duration; + } else { + $duration = CarbonInterval::instance($duration); + + if ($duration->totalMicroseconds < 0) { + $duration = CarbonInterval::seconds(0); + } + + $this->duration = $duration; + } + } + + /** + * Sleep for the given duration. + * + * @param \DateInterval|int|float $duration + * @return static + */ + public static function for($duration) + { + return new static($duration); + } + + /** + * Sleep until the given timestamp. + * + * @param \DateTimeInterface|int $timestamp + * @return static + */ + public static function until($timestamp) + { + if (is_int($timestamp)) { + $timestamp = Carbon::createFromTimestamp($timestamp); + } + + return new static(Carbon::now()->diff($timestamp)); + } + + /** + * Sleep for the given number of microseconds. + * + * @param int $duration + * @return static + */ + public static function usleep($duration) + { + return (new static($duration))->microseconds(); + } + + /** + * Sleep for the given number of seconds. + * + * @param int|float $duration + * @return static + */ + public static function sleep($duration) + { + return (new static($duration))->seconds(); + } + + /** + * Sleep for the given number of minutes. + * + * @return $this + */ + public function minutes() + { + $this->duration->add('minutes', $this->pullPending()); + + return $this; + } + + /** + * Sleep for one minute. + * + * @return $this + */ + public function minute() + { + return $this->minutes(); + } + + /** + * Sleep for the given number of seconds. + * + * @return $this + */ + public function seconds() + { + $this->duration->add('seconds', $this->pullPending()); + + return $this; + } + + /** + * Sleep for one second. + * + * @return $this + */ + public function second() + { + return $this->seconds(); + } + + /** + * Sleep for the given number of milliseconds. + * + * @return $this + */ + public function milliseconds() + { + $this->duration->add('milliseconds', $this->pullPending()); + + return $this; + } + + /** + * Sleep for one millisecond. + * + * @return $this + */ + public function millisecond() + { + return $this->milliseconds(); + } + + /** + * Sleep for the given number of microseconds. + * + * @return $this + */ + public function microseconds() + { + $this->duration->add('microseconds', $this->pullPending()); + + return $this; + } + + /** + * Sleep for on microsecond. + * + * @return $this + */ + public function microsecond() + { + return $this->microseconds(); + } + + /** + * Add additional time to sleep for. + * + * @param int|float $duration + * @return $this + */ + public function and($duration) + { + $this->pending = $duration; + + return $this; + } + + /** + * Handle the object's destruction. + * + * @return void + */ + public function __destruct() + { + if (! $this->shouldSleep) { + return; + } + + if ($this->pending !== null) { + throw new RuntimeException('Unknown duration unit.'); + } + + if (static::$fake) { + static::$sequence[] = $this->duration; + + return; + } + + $remaining = $this->duration->copy(); + + $seconds = (int) $remaining->totalSeconds; + + if ($seconds > 0) { + sleep($seconds); + + $remaining = $remaining->subSeconds($seconds); + } + + $microseconds = (int) $remaining->totalMicroseconds; + + if ($microseconds > 0) { + usleep($microseconds); + } + } + + /** + * Resolve the pending duration. + * + * @return int|float + */ + protected function pullPending() + { + if ($this->pending === null) { + $this->shouldNotSleep(); + + throw new RuntimeException('No duration specified.'); + } + + if ($this->pending < 0) { + $this->pending = 0; + } + + return tap($this->pending, function () { + $this->pending = null; + }); + } + + /** + * Stay awake and capture any attempts to sleep. + * + * @param bool $value + * @return void + */ + public static function fake($value = true) + { + static::$fake = $value; + + static::$sequence = []; + } + + /** + * Assert a given amount of sleeping occurred a specific number of times. + * + * @param \Closure $expected + * @param int $times + * @return void + */ + public static function assertSlept($expected, $times = 1) + { + $count = collect(static::$sequence)->filter($expected)->count(); + + PHPUnit::assertSame( + $times, + $count, + "The expected sleep was found [{$count}] times instead of [{$times}]." + ); + } + + /** + * Assert sleeping occurred a given number of times. + * + * @param int $expected + * @return void + */ + public static function assertSleptTimes($expected) + { + PHPUnit::assertSame($expected, $count = count(static::$sequence), "Expected [{$expected}] sleeps but found [{$count}]."); + } + + /** + * Assert the given sleep sequence was encountered. + * + * @param array $sequence + * @return void + */ + public static function assertSequence($sequence) + { + static::assertSleptTimes(count($sequence)); + + collect($sequence) + ->zip(static::$sequence) + ->eachSpread(function (?Sleep $expected, CarbonInterval $actual) { + if ($expected === null) { + return; + } + + PHPUnit::assertTrue( + $expected->shouldNotSleep()->duration->equalTo($actual), + vsprintf('Expected sleep duration of [%s] but actually slept for [%s].', [ + $expected->duration->cascade()->forHumans([ + 'options' => 0, + 'minimumUnit' => 'microsecond', + ]), + $actual->cascade()->forHumans([ + 'options' => 0, + 'minimumUnit' => 'microsecond', + ]), + ]) + ); + }); + } + + /** + * Assert that no sleeping occurred. + * + * @return void + */ + public static function assertNeverSlept() + { + return static::assertInsomniac(); + } + + /** + * Assert that no sleeping occurred. + * + * @return void + */ + public static function assertInsomniac() + { + foreach (static::$sequence as $duration) { + PHPUnit::assertSame(0, $duration->totalMicroseconds, vsprintf('Unexpected sleep duration of [%s] found.', [ + $duration->cascade()->forHumans([ + 'options' => 0, + 'minimumUnit' => 'microsecond', + ]), + ])); + } + } + + /** + * Indicate that the instance should not sleep. + * + * @return $this + */ + protected function shouldNotSleep() + { + $this->shouldSleep = false; + + return $this; + } +} diff --git a/src/Illuminate/Support/Timebox.php b/src/Illuminate/Support/Timebox.php index 0d41e0ac2d05..cb99a4a8492d 100644 --- a/src/Illuminate/Support/Timebox.php +++ b/src/Illuminate/Support/Timebox.php @@ -79,6 +79,6 @@ public function dontReturnEarly() */ protected function usleep(int $microseconds) { - usleep($microseconds); + Sleep::usleep($microseconds); } } diff --git a/src/Illuminate/Support/helpers.php b/src/Illuminate/Support/helpers.php index e428824ae65b..5f4d0d0aaf63 100755 --- a/src/Illuminate/Support/helpers.php +++ b/src/Illuminate/Support/helpers.php @@ -6,6 +6,7 @@ use Illuminate\Support\Env; use Illuminate\Support\HigherOrderTapProxy; use Illuminate\Support\Optional; +use Illuminate\Support\Sleep; use Illuminate\Support\Str; if (! function_exists('append_config')) { @@ -253,7 +254,7 @@ function retry($times, callable $callback, $sleepMilliseconds = 0, $when = null) $sleepMilliseconds = $backoff[$attempts - 1] ?? $sleepMilliseconds; if ($sleepMilliseconds) { - usleep(value($sleepMilliseconds, $attempts, $e) * 1000); + Sleep::usleep(value($sleepMilliseconds, $attempts, $e) * 1000); } goto beginning; diff --git a/tests/Support/SleepTest.php b/tests/Support/SleepTest.php new file mode 100644 index 000000000000..38b456893ec9 --- /dev/null +++ b/tests/Support/SleepTest.php @@ -0,0 +1,385 @@ +seconds(); + $end = microtime(true); + + $this->assertEqualsWithDelta(1, $end - $start, 0.03); + } + + public function testItSleepsForSecondsWithMilliseconds() + { + $start = microtime(true); + Sleep::for(1.5)->seconds(); + $end = microtime(true); + + $this->assertEqualsWithDelta(1.5, $end - $start, 0.03); + } + + public function testItCanFakeSleeping() + { + Sleep::fake(); + + $start = microtime(true); + Sleep::for(1.5)->seconds(); + $end = microtime(true); + + $this->assertEqualsWithDelta(0, $end - $start, 0.03); + } + + public function testItCanSpecifyMinutes() + { + Sleep::fake(); + + $sleep = Sleep::for(1.5)->minutes(); + + $this->assertSame($sleep->duration->totalMicroseconds, 90_000_000); + } + + public function testItCanSpecifyMinute() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->minute(); + + $this->assertSame($sleep->duration->totalMicroseconds, 60_000_000); + } + + public function testItCanSpecifySeconds() + { + Sleep::fake(); + + $sleep = Sleep::for(1.5)->seconds(); + + $this->assertSame($sleep->duration->totalMicroseconds, 1_500_000); + } + + public function testItCanSpecifySecond() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->second(); + + $this->assertSame($sleep->duration->totalMicroseconds, 1_000_000); + } + + public function testItCanSpecifyMilliseconds() + { + Sleep::fake(); + + $sleep = Sleep::for(1.5)->milliseconds(); + + $this->assertSame($sleep->duration->totalMicroseconds, 1_500); + } + + public function testItCanSpecifyMillisecond() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->millisecond(); + + $this->assertSame($sleep->duration->totalMicroseconds, 1_000); + } + + public function testItCanSpecifyMicroseconds() + { + Sleep::fake(); + + $sleep = Sleep::for(1.5)->microseconds(); + + // rounded as microseconds is the smallest unit supported... + $this->assertSame($sleep->duration->totalMicroseconds, 1); + } + + public function testItCanSpecifyMicrosecond() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->microsecond(); + + $this->assertSame($sleep->duration->totalMicroseconds, 1); + } + + public function testItCanChainDurations() + { + Sleep::fake(); + + $sleep = Sleep::for(1)->second() + ->and(500)->microseconds(); + + $this->assertSame($sleep->duration->totalMicroseconds, 1000500); + } + + public function testItCanUseDateInterval() + { + Sleep::fake(); + + $sleep = Sleep::for(CarbonInterval::seconds(1)->addMilliseconds(5)); + + $this->assertSame($sleep->duration->totalMicroseconds, 1_005_000); + } + + public function testItThrowsForUnknownTimeUnit() + { + try { + Sleep::for(5); + $this->fail(); + } catch (RuntimeException $e) { + $this->assertSame('Unknown duration unit.', $e->getMessage()); + } + } + + public function testItCanAssertSequence() + { + Sleep::fake(); + + Sleep::for(5)->seconds(); + Sleep::for(1)->seconds()->and(5)->microsecond(); + + Sleep::assertSequence([ + Sleep::for(5)->seconds(), + Sleep::for(1)->seconds()->and(5)->microsecond(), + ]); + } + + public function testItFailsSequenceAssertion() + { + Sleep::fake(); + + Sleep::for(5)->seconds(); + Sleep::for(1)->seconds()->and(5)->microseconds(); + + try { + Sleep::assertSequence([ + Sleep::for(5)->seconds(), + Sleep::for(9)->seconds()->and(8)->milliseconds(), + ]); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected sleep duration of [9 seconds 8 milliseconds] but actually slept for [1 second 5 microseconds].\nFailed asserting that false is true.", $e->getMessage()); + } + } + + public function testItCanUseSleep() + { + Sleep::fake(); + + Sleep::sleep(3); + + Sleep::assertSequence([ + Sleep::for(3)->seconds(), + ]); + } + + public function testItCanUseUSleep() + { + Sleep::fake(); + + Sleep::usleep(3); + + Sleep::assertSequence([ + Sleep::for(3)->microseconds(), + ]); + } + + public function testItCanSleepTillGivenTime() + { + Sleep::fake(); + Carbon::setTestNow(now()->startOfDay()); + + Sleep::until(now()->addMinute()); + + Sleep::assertSequence([ + Sleep::for(60)->seconds(), + ]); + } + + public function testItCanSleepTillGivenTimestamp() + { + Sleep::fake(); + Carbon::setTestNow(now()->startOfDay()); + + Sleep::until(now()->addMinute()->timestamp); + + Sleep::assertSequence([ + Sleep::for(60)->seconds(), + ]); + } + + public function testItSleepsForZeroTimeWithNegativeDateTime() + { + Sleep::fake(); + Carbon::setTestNow(now()->startOfDay()); + + Sleep::until(now()->subMinutes(100)); + + Sleep::assertSequence([ + Sleep::for(0)->seconds(), + ]); + } + + public function testSleepingForZeroTime() + { + Sleep::fake(); + + Sleep::for(0)->seconds(); + + try { + Sleep::assertSequence([ + Sleep::for(1)->seconds(), + ]); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected sleep duration of [1 second] but actually slept for [0 microseconds].\nFailed asserting that false is true.", $e->getMessage()); + } + } + + public function testItFailsWhenSequenceContainsTooManySleeps() + { + Sleep::fake(); + + Sleep::for(1)->seconds(); + + try { + Sleep::assertSequence([ + Sleep::for(1)->seconds(), + Sleep::for(1)->seconds(), + ]); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [2] sleeps but found [1].\nFailed asserting that 1 is identical to 2.", $e->getMessage()); + } + } + + public function testSilentlySetsDurationToZeroForNegativeValues() + { + Sleep::fake(); + + Sleep::for(-1)->seconds(); + + Sleep::assertSequence([ + Sleep::for(0)->seconds(), + ]); + } + + public function testItDoesntCaptureAssertionInstances() + { + Sleep::fake(); + + Sleep::for(1)->second(); + + Sleep::assertSequence([ + Sleep::for(1)->second(), + ]); + + try { + Sleep::assertSequence([ + Sleep::for(1)->second(), + Sleep::for(1)->second(), + ]); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [2] sleeps but found [1].\nFailed asserting that 1 is identical to 2.", $e->getMessage()); + } + } + + public function testItCanAssertNoSleepingOccurred() + { + Sleep::fake(); + + Sleep::assertInsomniac(); + + Sleep::for(0)->second(); + + // we still have not slept... + Sleep::assertInsomniac(); + + Sleep::for(1)->second(); + + try { + Sleep::assertInsomniac(); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Unexpected sleep duration of [1 second] found.\nFailed asserting that 1000000 is identical to 0.", $e->getMessage()); + } + } + + public function testItCanAssertSleepCount() + { + Sleep::fake(); + + Sleep::assertSleptTimes(0); + + Sleep::for(1)->second(); + + Sleep::assertSleptTimes(1); + + try { + Sleep::assertSleptTimes(0); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [0] sleeps but found [1].\nFailed asserting that 1 is identical to 0.", $e->getMessage()); + } + + try { + Sleep::assertSleptTimes(2); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("Expected [2] sleeps but found [1].\nFailed asserting that 1 is identical to 2.", $e->getMessage()); + } + } + + public function testAssertSlept() + { + Sleep::fake(); + + Sleep::assertSlept(fn () => true, 0); + + try { + Sleep::assertSlept(fn () => true); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("The expected sleep was found [0] times instead of [1].\nFailed asserting that 0 is identical to 1.", $e->getMessage()); + } + + Sleep::for(5)->seconds(); + + Sleep::assertSlept(fn (CarbonInterval $duration) => $duration->totalSeconds === 5); + + try { + Sleep::assertSlept(fn (CarbonInterval $duration) => $duration->totalSeconds === 5, 2); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("The expected sleep was found [1] times instead of [2].\nFailed asserting that 1 is identical to 2.", $e->getMessage()); + } + + try { + Sleep::assertSlept(fn (CarbonInterval $duration) => $duration->totalSeconds === 6); + $this->fail(); + } catch (AssertionFailedError $e) { + $this->assertSame("The expected sleep was found [0] times instead of [1].\nFailed asserting that 0 is identical to 1.", $e->getMessage()); + } + } +}