Skip to content

Commit

Permalink
Version 2.0 (#2)
Browse files Browse the repository at this point in the history
* add Windows support

* Allow Symfony 7 and CS

* Added CI workflow

* Failover if supervision is not possible

* use posix_getpgid

* update comment

* Split into providers

* Add static test methods

---------

Co-authored-by: Fritz Michael Gschwantner <fmg@inspiredminds.at>
  • Loading branch information
Toflar and fritzmg authored Mar 11, 2024
1 parent 64bc574 commit 9c30442
Show file tree
Hide file tree
Showing 11 changed files with 287 additions and 30 deletions.
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: Toflar
75 changes: 75 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: CI

on:
pull_request: ~
schedule:
- cron: 0 13 * * MON

jobs:
cs:
name: Coding Style
runs-on: ubuntu-latest
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none

- name: Checkout
uses: actions/checkout@v3

- name: Install the dependencies
run: composer update --no-interaction --no-suggest

- name: Run the CS fixer
run: composer cs-fixer

tests-linux:
name: 'Linux: PHP ${{ matrix.php }}'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3']
steps:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none

- name: Checkout
uses: actions/checkout@v3

- name: Install the dependencies
run: composer update --no-interaction --no-suggest

- name: Run the unit tests
run: composer unit-tests

tests-windows:
name: 'Windows: PHP ${{ matrix.php }}'
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
php: ['8.1', '8.2', '8.3']
steps:
- name: Set up Cygwin
uses: egor-tensin/setup-cygwin@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none

- name: Checkout
uses: actions/checkout@v3

- name: Install the dependencies
run: composer update --no-interaction --no-suggest

- name: Run the unit tests
run: composer unit-tests
9 changes: 6 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"type": "library",
"require": {
"php": "^8.1",
"symfony/process": "^6.0",
"symfony/lock": "^6.0",
"symfony/filesystem": "^6.0"
"symfony/process": "^6.0 || ^7.0",
"symfony/lock": "^6.0 || ^7.0",
"symfony/filesystem": "^6.0 || ^7.0"
},
"require-dev": {
"phpunit/phpunit": "^10.2",
Expand All @@ -33,5 +33,8 @@
"allow-plugins": {
"terminal42/contao-build-tools": true
}
},
"scripts": {
"unit-tests": "@php vendor/bin/phpunit"
}
}
1 change: 0 additions & 1 deletion src/BasicCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ public function __construct(
/** @var \Closure():Process */
private readonly \Closure $createProcess,
) {

}

public function getIdentifier(): string
Expand Down
19 changes: 19 additions & 0 deletions src/Provider/PosixProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Toflar\CronjobSupervisor\Provider;

class PosixProvider implements ProviderInterface
{
public function isSupported(): bool
{
return \function_exists('posix_getpgid');
}

public function isPidRunning(int $pid): bool
{
// posix_getpgid returns false, if the process is not running anymore
return false !== posix_getpgid($pid);
}
}
12 changes: 12 additions & 0 deletions src/Provider/ProviderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Toflar\CronjobSupervisor\Provider;

interface ProviderInterface
{
public function isSupported(): bool;

public function isPidRunning(int $pid): bool;
}
40 changes: 40 additions & 0 deletions src/Provider/PsProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Toflar\CronjobSupervisor\Provider;

use Symfony\Component\Process\Process;

class PsProvider implements ProviderInterface
{
public function isSupported(): bool
{
try {
$process = new Process(['ps']);
$process->mustRun();

return true;
} catch (\Throwable) {
return false;
}
}

public function isPidRunning(int $pid): bool
{
try {
$process = new Process(['ps', '-p', $pid]);
$process->mustRun();

// Check for defunct output. If the process was started within this very process,
// it will still be listed, although it's actually finished.
if (str_contains($process->getOutput(), '<defunct>')) {
return false;
}

return true;
} catch (\Throwable) {
return false;
}
}
}
39 changes: 39 additions & 0 deletions src/Provider/WindowsTaskListProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Toflar\CronjobSupervisor\Provider;

use Symfony\Component\Process\Process;

class WindowsTaskListProvider implements ProviderInterface
{
public function isSupported(): bool
{
if ('\\' !== \DIRECTORY_SEPARATOR) {
return false;
}

try {
$process = new Process(['tasklist']);
$process->mustRun();

return true;
} catch (\Throwable) {
return false;
}
}

public function isPidRunning(int $pid): bool
{
try {
$process = new Process(['tasklist', '/FI', "PID eq $pid"]);
$process->mustRun();

// Symfony Process starts Windows processes via cmd.exe
return str_contains($process->getOutput(), 'cmd.exe');
} catch (\Throwable) {
return false;
}
}
}
97 changes: 76 additions & 21 deletions src/Supervisor.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,17 @@
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\FlockStore;
use Symfony\Component\Process\Process;
use Toflar\CronjobSupervisor\Provider\PosixProvider;
use Toflar\CronjobSupervisor\Provider\ProviderInterface;
use Toflar\CronjobSupervisor\Provider\PsProvider;
use Toflar\CronjobSupervisor\Provider\WindowsTaskListProvider;

class Supervisor
{
private const LOCK_NAME = 'cronjob-supervisor-lock';

private readonly LockFactory $lockFactory;

private readonly Filesystem $filesystem;

/**
Expand All @@ -31,14 +36,66 @@ class Supervisor
*/
private array $childProcesses = [];

public function __construct(private readonly string $storageDirectory)
{
/**
* @param array<ProviderInterface> $providers
*/
private function __construct(
private readonly string $storageDirectory,
private readonly array $providers,
) {
$this->lockFactory = new LockFactory(new FlockStore($storageDirectory));
$this->filesystem = new Filesystem();

$this->filesystem->mkdir($this->storageDirectory);
}

public static function withDefaultProviders(string $storageDirectory): self
{
return new self($storageDirectory, self::getDefaultProviders());
}

public static function getDefaultProviders(): array
{
return [
new WindowsTaskListProvider(),
new PosixProvider(),
new PsProvider(),
];
}

/**
* @param array<ProviderInterface> $providers
*/
public static function withProviders(string $storageDirectory, array $providers): self
{
return new self($storageDirectory, $providers);
}

/**
* @param array<ProviderInterface> $providers
*/
public static function canSuperviseWithProviders(array $providers): bool
{
foreach ($providers as $provider) {
if ($provider->isSupported()) {
return true;
}
}

return false;
}

public function canSupervise(): bool
{
foreach ($this->providers as $provider) {
if ($provider->isSupported()) {
return true;
}
}

return false;
}

public function withCommand(CommandInterface $command): self
{
$clone = clone $this;
Expand All @@ -52,6 +109,10 @@ public function withCommand(CommandInterface $command): self
*/
public function supervise(\Closure|null $onTick = null): void
{
if (!$this->canSupervise()) {
throw new \LogicException('No provider supported, cannot supervise!');
}

$end = time() + 55;
$tick = 1;

Expand All @@ -69,8 +130,9 @@ public function supervise(\Closure|null $onTick = null): void
++$tick;
}

// Okay, we are done supervising. Now we might have child processes that are still running. We have to wait
// for them to finish. Only then we can exit ourselves otherwise we'd kill the children
// Okay, we are done supervising. Now we might have child processes that are
// still running. We have to wait for them to finish. Only then we can exit
// ourselves otherwise we'd kill the children
while ($this->hasRunningChildProcesses()) {
sleep(5);
}
Expand Down Expand Up @@ -105,7 +167,7 @@ function (): void {

// Save state
$this->filesystem->dumpFile($this->getStorageFile(), json_encode($this->storage, JSON_THROW_ON_ERROR));
}
},
);
}

Expand Down Expand Up @@ -139,8 +201,8 @@ private function padCommand(CommandInterface $command): void
if (null !== $process->getPid()) {
$this->storage[$command->getIdentifier()][] = $process->getPid();

// Remember started child processes because we have to remain running in order for those child processes
// not to get killed.
// Remember started child processes because we have to remain running in order
// for those child processes not to get killed.
$this->childProcesses[$process->getPid()] = $process;
}
}
Expand All @@ -154,8 +216,8 @@ private function getStorageFile(): string

private function executeLocked(\Closure $closure): void
{
// Library is meant to be used with minutely cronjobs. Thus, the default ttl of 300 is enough and does not need
// to be configurable.
// Library is meant to be used with minutely cronjobs. Thus, the default ttl of
// 300 is enough and does not need to be configurable.
$lock = $this->lockFactory->createLock(self::LOCK_NAME);
if (!$lock->acquire()) {
return;
Expand All @@ -167,19 +229,12 @@ private function executeLocked(\Closure $closure): void

private function isRunningPid(int $pid): bool
{
$process = new Process(['ps', '-p', $pid]);
$exitCode = $process->run();

if (0 !== $exitCode) {
return false;
}

// Check for defunct output. If the process was started within this very process, it will still be listed,
// although it's actually finished.
if (str_contains($process->getOutput(), '<defunct>')) {
return false;
foreach ($this->providers as $provider) {
if ($provider->isSupported()) {
return $provider->isPidRunning($pid);
}
}

return true;
return false;
}
}
Loading

0 comments on commit 9c30442

Please sign in to comment.