-
-
Save voku/e13e26adfbe473d32b7048940af90de3 to your computer and use it in GitHub Desktop.
PHP Tick-Based Timeout Wrapper (No pcntl Required)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
// ⏱️ v1 | Simple, automatic timeout with default polling interval | |
timeoutForEach(5, range(1, 3), static fn (int $value) => doThing()); | |
// ⏱️ v2 | Simple, automatic timeout with default polling interval | |
$upper = timeoutMap(0.5, ['alice', 'bob', 'charlie'], static fn ($name) => strtoupper($name)); | |
//\PHPStan\dumpType($upper); // Dumped type: list<uppercase-string> | |
// 👁️ Timeout-aware loop, low CPU overhead | |
timeout(2.5, static function (TimeoutGuard $guard): void { | |
foreach (range(1, 10000000) as $i) { | |
$guard->maybeCheck(); | |
if ($i % 1000000 === 0) { | |
echo "Processing 👁: {$i}\n"; | |
} | |
} | |
}); | |
// 💣 Force stricter interval checks (e.g. every x iterations) | |
timeout(2.5, static function (TimeoutGuard $guard): void { | |
foreach (range(1, 10000000) as $i) { | |
if ($i % 100000 === 0) { | |
$guard->checkNow(); | |
} | |
if ($i % 1000000 === 0) { | |
echo "Processing 💣: {$i}\n"; | |
} | |
} | |
}); | |
// 📈 Report diagnostic stats (elapsed time, checks, etc.) | |
$report = null; | |
try { | |
timeout(2.0, static function (TimeoutGuard $guard) use (&$report): void { | |
foreach (range(1, 10_000_000) as $i) { | |
$guard->maybeCheck(); | |
if ($i % 2_000_000 === 0) { | |
echo "Processing 📈: {$i}\n"; | |
} | |
} | |
$guard->stop(); | |
$report = $guard->report(); | |
}, 0.001); | |
echo "✅ Finished within time.\n"; | |
} catch (TimeoutException $e) { | |
echo "⛔ Timeout: {$e->getMessage()}\n"; | |
} | |
if ($report !== null) { | |
echo "\n📋 Timeout Report:\n"; | |
foreach ($report as $key => $value) { | |
echo " - {$key}: {$value}\n"; | |
} | |
} | |
// -------------------- | |
/** | |
* Run a closure with timeout protection. | |
* | |
* @template TClosureResult | |
* | |
* @param float $seconds | |
* @param Closure(TimeoutGuard): TClosureResult $callback | |
* @param float $checkInterval | |
* | |
* @return TClosureResult | |
* | |
* @throws TimeoutException | |
*/ | |
function timeout( | |
float $seconds, | |
Closure $callback, | |
float $checkInterval = 0.01 | |
): mixed { | |
return TimeoutGuard::run($seconds, $callback, $checkInterval); | |
} | |
/** | |
* Timeout-aware array_map. | |
* | |
* @template TIn | |
* @template TOut | |
* | |
* @param float $timeoutSeconds | |
* @param iterable<TIn> $items | |
* @param Closure(TIn, int, TimeoutGuard): TOut $callback | |
* @param float $checkInterval | |
* | |
* @return list<TOut> | |
* | |
* @throws TimeoutException | |
*/ | |
function timeoutMap( | |
float $timeoutSeconds, | |
iterable $items, | |
Closure $callback, | |
float $checkInterval = 0.01 | |
): array { | |
return TimeoutGuard::run($timeoutSeconds, function (TimeoutGuard $guard) use ($items, $callback): array { | |
$result = []; | |
$i = 0; | |
foreach ($items as $item) { | |
$guard->maybeCheck(); | |
$result[] = $callback($item, $i++, $guard); | |
} | |
return $result; | |
}, $checkInterval); | |
} | |
/** | |
* Timeout-aware foreach loop. | |
* | |
* @template TValue | |
* | |
* @param float $timeoutSeconds | |
* @param iterable<TValue> $items | |
* @param Closure(TValue, int, TimeoutGuard): void $callback | |
* @param float $checkInterval | |
* | |
* @throws TimeoutException | |
*/ | |
function timeoutForEach( | |
float $timeoutSeconds, | |
iterable $items, | |
Closure $callback, | |
float $checkInterval = 0.01 | |
): void { | |
TimeoutGuard::run($timeoutSeconds, function (TimeoutGuard $guard) use ($items, $callback): void { | |
$i = 0; | |
foreach ($items as $item) { | |
$guard->maybeCheck(); | |
$callback($item, $i++, $guard); | |
} | |
}, $checkInterval); | |
} | |
// -------------------- | |
final class TimeoutGuard | |
{ | |
private float $deadline; | |
private float $startTime; | |
private float $checkInterval; | |
private float $nextCheckTime; | |
private int $checkCount = 0; | |
private bool $running = false; | |
private float $finalElapsedTime = 0.0; | |
private const DEFAULT_CHECK_INTERVAL = 0.01; | |
private const MIN_CHECK_INTERVAL = 0.001; | |
/** | |
* Run a closure with timeout protection. | |
* | |
* @template TClosureResult | |
* | |
* @param float $timeoutSeconds | |
* @param Closure(TimeoutGuard): TClosureResult $callback | |
* @param float $checkIntervalSeconds | |
* | |
* @return TClosureResult | |
* | |
* @throws TimeoutException | |
*/ | |
public static function run( | |
float $timeoutSeconds, | |
Closure $callback, | |
float $checkIntervalSeconds = self::DEFAULT_CHECK_INTERVAL | |
): mixed { | |
$guard = new self($timeoutSeconds, $checkIntervalSeconds); | |
try { | |
return $callback($guard); | |
} catch (Throwable $e) { | |
throw $e; | |
} finally { | |
$guard->stop(); | |
} | |
} | |
private function __construct( | |
readonly private float $timeoutSeconds, | |
float $checkIntervalSeconds = self::DEFAULT_CHECK_INTERVAL | |
) { | |
if ($timeoutSeconds <= 0) { | |
throw new RuntimeException('Timeout must be a positive number.'); | |
} | |
if ($checkIntervalSeconds > $timeoutSeconds) { | |
throw new RuntimeException('Check interval must not exceed total timeout.'); | |
} | |
$this->checkInterval = max($checkIntervalSeconds, self::MIN_CHECK_INTERVAL); | |
$this->startTime = microtime(true); | |
$this->deadline = $this->startTime + $this->timeoutSeconds; | |
$this->nextCheckTime = $this->startTime + $this->checkInterval; | |
$this->running = true; | |
} | |
public function stop(): void | |
{ | |
$this->finalElapsedTime = microtime(true) - $this->startTime; | |
$this->running = false; | |
} | |
public function checkNow(): void | |
{ | |
if (! $this->running) { | |
throw new RuntimeException('TimeoutGuard is not running.'); | |
} | |
$now = microtime(true); | |
$this->checkCount++; | |
if ($now >= $this->deadline) { | |
$elapsed = $now - $this->startTime; | |
throw new TimeoutException(sprintf('Execution timed out after %.4f seconds', $elapsed)); | |
} | |
$this->nextCheckTime = $now + $this->checkInterval; | |
} | |
public function maybeCheck(): void | |
{ | |
if (! $this->running) { | |
throw new RuntimeException('TimeoutGuard is not running.'); | |
} | |
if (microtime(true) >= $this->nextCheckTime) { | |
$this->checkNow(); | |
} | |
} | |
public function getElapsedTime(): float | |
{ | |
return $this->running | |
? microtime(true) - $this->startTime | |
: $this->finalElapsedTime; | |
} | |
public function getCheckCount(): int | |
{ | |
return $this->checkCount; | |
} | |
public function extend(float $seconds): void | |
{ | |
if (! $this->running) { | |
throw new RuntimeException('Cannot extend a stopped TimeoutGuard.'); | |
} | |
if ($seconds <= 0) { | |
throw new RuntimeException('Extension duration must be positive.'); | |
} | |
$this->deadline += $seconds; | |
} | |
/** | |
* @return array{ | |
* timeoutSeconds: float, | |
* elapsedTime: float, | |
* checkCount: int, | |
* checkInterval: float, | |
* running: 'YES'|'NO' | |
* } | |
*/ | |
public function report(): array | |
{ | |
return [ | |
'timeoutSeconds' => $this->timeoutSeconds, | |
'elapsedTime' => $this->getElapsedTime(), | |
'checkCount' => $this->checkCount, | |
'checkInterval' => $this->checkInterval, | |
'running' => $this->running ? 'YES' : 'NO', | |
]; | |
} | |
} | |
final class TimeoutException extends RuntimeException | |
{ | |
} | |
// ----------------- | |
function doThing(): void { sleep(1); } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment