Skip to content

Commit 88b0aac

Browse files
Merge pull request #11 from Vectorial1024/expiration
Feature: task time limits (and other related stuff)
2 parents a91e913 + 8b7371c commit 88b0aac

11 files changed

+567
-5
lines changed

.github/workflows/macos_l11.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ jobs:
2525
with:
2626
php-version: '8.2'
2727

28+
- name: "Homebrew: Install GNU Core Utilities"
29+
run: yes | brew install coreutils
30+
2831
- name: Validate composer.json and composer.lock
2932
run: composer validate --strict
3033

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ Utilize Laravel Processes to run PHP code asynchronously.
1313
- Restrictions from `laravel/serializable-closure` apply (see [their README](https://github.com/laravel/serializable-closure))
1414
- Hands-off execution: no built-in result-checking, check the results yourself (e.g. via database, file cache, etc)
1515

16+
This library internally uses an Artisan command to run the async code, which is similar to Laravel 11 [Concurrency](https://laravel.com/docs/11.x/concurrency).
17+
1618
## Why should I want this?
1719
This library is very helpful for these cases:
18-
- You want a minimal-setup async for easy vertical scaling
20+
- You want a cross-platform minimal-setup async for easy vertical scaling
1921
- You want to start quick-and-dirty async tasks right now (e.g. prefetching resources, pinging remote, etc.)
2022
- Best is if your task only has very few lines of code
2123
- Laravel 11 [Concurrency](https://laravel.com/docs/11.x/concurrency) is too limiting; e.g.:
2224
- You want to do something else while waiting for results
25+
- You want to conveniently limit the max (real) execution time of the concurrent tasks
26+
- And perhaps more!
2327

2428
Of course, if you are considering extreme scaling (e.g. Redis queues in Laravel, multi-worker clusters, etc.) or guaranteed task execution, then this library is obviously not for you.
2529

@@ -32,6 +36,12 @@ composer require vectorial1024/laravel-process-async
3236

3337
This library supports Unix and Windows; see the Testing section for more details.
3438

39+
### Extra requirements for Unix
40+
If you are on Unix, check that you also have the following:
41+
- GNU Core Utilities (`coreutils`)
42+
- MacOS do `brew install coreutils`!
43+
- Other Unix distros should check if `coreutils` is preinstalled
44+
3545
## Change log
3646
Please see `CHANGELOG.md`.
3747

@@ -59,6 +69,24 @@ $task->start();
5969
// the task is now run in another PHP process, and will not report back to this PHP process.
6070
```
6171

72+
### Task time limits
73+
You can set task time limits before you start them, but you cannot change them after the tasks are started. When the time limit is reached, the async task is killed.
74+
75+
The default time limit is 30 real seconds. You can also choose to not set any time limit, in this case the (CLI) PHP `max_execution_time` directive will control the time limit.
76+
77+
Note: `AsyncTaskInterface` contains an implementable method `handleTimeout` for you to define timeout-related cleanups (e.g. write to some log that the task has timed out). This method is still called when the PHP `max_execution_time` directive is triggered.
78+
79+
```php
80+
// start with the default time limit...
81+
$task->start();
82+
83+
// start task with a different time limit...
84+
$task->withTimeLimit(15)->start();
85+
86+
// ...or not have any limits at all (beware of orphaned processes!)
87+
$task->withoutTimeLimit()->start();
88+
```
89+
6290
## Testing
6391
PHPUnit via Composer script:
6492
```sh

src/AsyncTask.php

Lines changed: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Vectorial1024\LaravelProcessAsync;
46

57
use Closure;
68
use Illuminate\Process\InvokedProcess;
79
use Illuminate\Support\Facades\Process;
810
use Laravel\SerializableClosure\SerializableClosure;
11+
use LogicException;
912
use loophp\phposinfo\OsInfo;
13+
use RuntimeException;
1014

1115
/**
1216
* The common handler of an AsyncTask; this can be a closure (will be wrapped inside AsyncTask) or an interface instance.
@@ -25,6 +29,65 @@ class AsyncTask
2529
*/
2630
private InvokedProcess|null $runnerProcess = null;
2731

32+
/**
33+
* The maximum real time (in seconds) this task is allowed to run.
34+
* @var int|null
35+
*/
36+
private int|null $timeLimit = 30;
37+
38+
/**
39+
* The value of constant("LARAVEL_START") for future usage. Apparently, constants are not available during shutdown functions.
40+
* @var float|null
41+
*/
42+
private float|null $laravelStartVal = null;
43+
44+
/**
45+
* On Unix only. Indicates the process ID that can be used to track the "time elapsed" stat, which resolves to the following:
46+
* - if the task was started under the `timeout` command, then the PID of said `timeout` command
47+
* - else (i.e., started without time limit), the self PID
48+
*
49+
* If not yet initialized or on Windows, then will be 0, which indicates an invalid PID.
50+
* @var int
51+
*/
52+
private int $timerProcID = 0;
53+
54+
/**
55+
* On Unix only. Indicates whether a SIGINT has been received.
56+
* @var bool
57+
*/
58+
private bool $hasSigInt = false;
59+
60+
/**
61+
* The string constant name for constant('LARAVEL_START'). Mainly to keep the code clean.
62+
* @var string
63+
*/
64+
private const LARAVEL_START = "LARAVEL_START";
65+
66+
/**
67+
* The bitmask that can filter for fatal runtime errors.
68+
*
69+
* Fatal errors other than the specific "time limit exceeded" error must not trigger the timeout handlers.
70+
*/
71+
private const FATAL_ERROR_BITMASK = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR;
72+
73+
/**
74+
* Indicates whether GNU coreutils is found in the system; in particular, we are looking for the timeout command inside coreutils.
75+
*
76+
* If null, indicates we haven't checked this yet.
77+
*
78+
* Always null in Windows since Windows-side does not require GNU coreutils.
79+
* @var bool|null
80+
*/
81+
private static bool|null $hasGnuCoreUtils = null;
82+
83+
/**
84+
* The name of the found timeout command inside GNU coreutils.
85+
*
86+
* It is known that older MacOS environments might have "gtimeout" instead of "timeout".
87+
* @var string|null
88+
*/
89+
private static string|null $timeoutCmdName = null;
90+
2891
/**
2992
* Creates an AsyncTask instance.
3093
* @param Closure|AsyncTaskInterface $theTask The task to be executed in the background.
@@ -48,6 +111,35 @@ public function __construct(Closure|AsyncTaskInterface $theTask)
48111
public function run(): void
49112
{
50113
// todo startup configs
114+
// write down the LARAVEL_START constant value for future usage
115+
$this->laravelStartVal = defined(self::LARAVEL_START) ? constant("LARAVEL_START") : null;
116+
117+
// install a timeout detector
118+
// this single function checks all kinds of timeouts
119+
register_shutdown_function([$this, 'shutdownCheckTaskTimeout']);
120+
if (OsInfo::isWindows()) {
121+
// windows can just use PHP's time limit
122+
set_time_limit($this->timeLimit);
123+
} else {
124+
// assume anything not Windows to be Unix
125+
// we already set it to kill this task after the timeout, so we just need to install a listener to catch the signal and exit gracefully
126+
pcntl_async_signals(true);
127+
pcntl_signal(SIGINT, function () {
128+
// sicne we are already running with nohup, we can use SIGINT to indicate that a timeout has occurred.
129+
// exit asap so that our error checking inside shutdown functions can take place outside of the usual max_execution_time limit
130+
$this->hasSigInt = true;
131+
exit();
132+
});
133+
134+
// and we also need to see the command name of our parent, to correctly track time
135+
$this->timerProcID = getmypid();
136+
$parentPid = posix_getppid();
137+
$parentCmd = exec("ps -p $parentPid -o comm=");
138+
if ($parentCmd === "timeout" || $parentCmd === "gtimeout") {
139+
// we should use the parent instead to time this task
140+
$this->timerProcID = $parentPid;
141+
}
142+
}
51143

52144
// then, execute the task itself
53145
if ($this->theTask instanceof SerializableClosure) {
@@ -77,12 +169,32 @@ public function start(): void
77169
if (OsInfo::isWindows()) {
78170
// basically, in windows, it is too tedioous to check whether we are in cmd or ps,
79171
// but we require cmd (ps won't work here), so might as well force cmd like this
172+
// windows has real max time limit
80173
$this->runnerProcess = Process::quietly()->start("cmd /c start /b $baseCommand");
81174
return;
82175
}
83176
// assume anything not windows to be unix
84177
// unix use nohup
85-
$this->runnerProcess = Process::quietly()->start("nohup $baseCommand");
178+
// check time limit settings
179+
$timeoutClause = "";
180+
if ($this->timeLimit > 0) {
181+
// do we really have timeout here?
182+
if (static::$hasGnuCoreUtils === null) {
183+
// haven't checked before; check
184+
$tmpOut = exec("command -v timeout || command -v gtimeout");
185+
$cmdName = !empty($tmpOut) ? $tmpOut : null;
186+
unset($tmpOut);
187+
static::$hasGnuCoreUtils = $cmdName !== null;
188+
static::$timeoutCmdName = $cmdName;
189+
}
190+
if (static::$hasGnuCoreUtils === false) {
191+
// can't do anything without GNU coreutils!
192+
throw new RuntimeException("AsyncTask time limit requires GNU coreutils, but GNU coreutils was not installed");
193+
}
194+
// 2 is INT signal
195+
$timeoutClause = static::$timeoutCmdName . " -s 2 {$this->timeLimit}";
196+
}
197+
$this->runnerProcess = Process::quietly()->start("nohup $timeoutClause $baseCommand >/dev/null 2>&1");
86198
}
87199

88200
/**
@@ -126,4 +238,125 @@ public static function fromBase64Serial(string $serial): ?static
126238
return null;
127239
}
128240
}
241+
242+
/**
243+
* Returns the maximum real time this task is allowed to run. This also includes time spent on sleeping and waiting!
244+
*
245+
* Null indicates unlimited time.
246+
* @return int|null The time limit in seconds.
247+
*/
248+
public function getTimeLimit(): int|null
249+
{
250+
return $this->timeLimit;
251+
}
252+
253+
/**
254+
* Sets the maximum real time this task is allowed to run. Chainable.
255+
*
256+
* When the task reaches the time limit, the timeout handler (if exists) will be called.
257+
* @param int $seconds The time limit in seconds.
258+
* @return AsyncTask $this for chaining.
259+
*/
260+
public function withTimeLimit(int $seconds): static
261+
{
262+
if ($seconds == 0) {
263+
throw new LogicException("AsyncTask time limit must be positive (hint: use withoutTimeLimit() for no time limits)");
264+
}
265+
if ($seconds < 0) {
266+
throw new LogicException("AsyncTask time limit must be positive");
267+
}
268+
$this->timeLimit = $seconds;
269+
return $this;
270+
}
271+
272+
/**
273+
* Sets this task to run with no time limit (PHP INI `max_execution_time` may apply). Chainable.
274+
* @return AsyncTask $this for chaining.
275+
*/
276+
public function withoutTimeLimit(): static
277+
{
278+
$this->timeLimit = null;
279+
return $this;
280+
}
281+
282+
/**
283+
* A shutdown function.
284+
*
285+
* Upon shutdown, checks whether this is due to the task timing out, and if so, triggers the timeout handler.
286+
* @return void
287+
*/
288+
protected function shutdownCheckTaskTimeout(): void
289+
{
290+
if (!$this->hasTimedOut()) {
291+
// shutdown due to other reasons; skip
292+
return;
293+
}
294+
295+
// timeout!
296+
// trigger the timeout handler
297+
if ($this->theTask instanceof AsyncTaskInterface) {
298+
$this->theTask->handleTimeout();
299+
}
300+
}
301+
302+
/**
303+
* During shutdown, checks whether this shutdown satisfies the "task timed out shutdown" condition.
304+
* @return bool True if this task is timed out according to our specifications.
305+
*/
306+
private function hasTimedOut(): bool
307+
{
308+
// we perform a series of checks to see if this task has timed out
309+
310+
// dedicated SIGINT indicates a timeout
311+
if ($this->hasSigInt) {
312+
return true;
313+
}
314+
315+
// runtime timeout triggers a PHP fatal error
316+
// this can happen on Windows by our specification, or on Unix when the actual CLI PHP time limit is smaller than the time limit of this task
317+
$lastError = error_get_last();
318+
if ($lastError !== null && ($lastError['type'] & self::FATAL_ERROR_BITMASK)) {
319+
// has fatal error; is it our timeout error?
320+
return str_contains($lastError['message'], "Maximum execution time");
321+
}
322+
unset($lastError);
323+
324+
// the remaining checks use the time-limit variable, so if it is unset, then there is nothing to check
325+
if ($this->timeLimit <= 0) {
326+
return false;
327+
}
328+
329+
// not a runtime timeout; one of the following:
330+
// it ended within the time limit; or
331+
// on Unix, it ran out of time so it is getting a SIGTERM from us; or
332+
// it somehow ran out of time, and is being manually detected and killed
333+
if ($this->laravelStartVal !== null) {
334+
// this is very important; in some test cases, this is being run directly by PHPUnit, and so LARAVEL_START will be null
335+
// in this case, we have no idea when this task has started running, so we cannot deduce any timeout statuses
336+
337+
// check LARAVEL_START with microtime
338+
$timeElapsed = microtime(true) - $this->laravelStartVal;
339+
if ($timeElapsed >= $this->timeLimit) {
340+
// yes
341+
return true;
342+
}
343+
344+
// if we are on Unix, and when we have set a task time limit, then the LARAVEL_START value is inaccurate
345+
// because there will always be a small but significant delay between `timeout` start time and PHP start time.
346+
// in this case, we will look at the pre-determined timer PID to ask about the actual elapsed time through the kernel's proc data
347+
// this method should be slower than the microtime method
348+
if (OsInfo::isUnix()) {
349+
// get time elapsed in seconds
350+
$tempOut = exec("ps -p {$this->timerProcID} -o etimes=");
351+
// this must exist (we are still running!), otherwise it indicates the kernel is broken and we can go grab a chicken dinner instead
352+
$timeElapsed = (int) $tempOut;
353+
unset($tempOut);
354+
// it seems like etimes can get random off-by-1 inaccuracies (e.g. timeout supposed to be 7, but etimes sees 6.99999... and prints "6")
355+
return $timeElapsed >= $this->timeLimit;
356+
}
357+
}
358+
359+
// didn't see anything; assume is no
360+
return false;
361+
}
129362
}

src/AsyncTaskInterface.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
namespace Vectorial1024\LaravelProcessAsync;
46

57
/**
@@ -16,4 +18,12 @@ interface AsyncTaskInterface
1618
* @return void
1719
*/
1820
public function execute(): void;
21+
22+
/**
23+
* Cleans up the task when the task runner has run out of time specified by its time limit.
24+
*
25+
* Note: there is no need to call exit() again in this function.
26+
* @return void
27+
*/
28+
public function handleTimeout(): void;
1929
}

0 commit comments

Comments
 (0)