Skip to content

Commit

Permalink
Implemented file monitor watcher
Browse files Browse the repository at this point in the history
  • Loading branch information
luzrain committed Feb 14, 2024
1 parent c6f554a commit ec2ba91
Show file tree
Hide file tree
Showing 8 changed files with 285 additions and 2 deletions.
56 changes: 56 additions & 0 deletions src/FileMonitorWatcher/FileMonitorWatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Luzrain\PhpRunnerBundle\FileMonitorWatcher;

use Psr\Log\LoggerInterface;
use Revolt\EventLoop\Driver;

/**
* @psalm-suppress UndefinedPropertyAssignment
* @psalm-suppress InaccessibleProperty
*/
abstract class FileMonitorWatcher
{
protected readonly LoggerInterface $logger;
protected readonly array $sourceDir;
private readonly array $filePattern;
private readonly \Closure $reloadCallback;

public static function create(
LoggerInterface $logger,
/** @var list<string> $sourceDir */
array $sourceDir,
/** @var list<string> $filePattern */
array $filePattern,
float $pollingInterval,
\Closure $reloadCallback,
): self {
$watcher = \extension_loaded('inotify') ? new InotifyMonitorWatcher() : new PollingMonitorWatcher($pollingInterval);
$watcher->logger = $logger;
$watcher->sourceDir = \array_filter($sourceDir, \is_dir(...));
$watcher->filePattern = $filePattern;
$watcher->reloadCallback = $reloadCallback;

return $watcher;
}

final protected function isPatternMatch(string $filename): bool
{
foreach ($this->filePattern as $pattern) {
if (\fnmatch($pattern, $filename)) {
return true;
}
}

return false;
}

final protected function reload(): void
{
($this->reloadCallback)();
}

abstract public function start(Driver $eventLoop): void;
}
96 changes: 96 additions & 0 deletions src/FileMonitorWatcher/InotifyMonitorWatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace Luzrain\PhpRunnerBundle\FileMonitorWatcher;

use Revolt\EventLoop\Driver;

/**
* @psalm-suppress UndefinedConstant
* @psalm-suppress PropertyNotSetInConstructor
*/
final class InotifyMonitorWatcher extends FileMonitorWatcher
{
private const REBOOT_DELAY = 0.3;

/** @var resource */
private mixed $fd;
/** @var array<int, string> */
private array $pathByWd = [];
private \Closure|null $delayedRebootCallback = null;

protected function __construct()
{
}

public function start(Driver $eventLoop): void
{
$this->fd = \inotify_init();
\stream_set_blocking($this->fd, false);

foreach ($this->sourceDir as $dir) {
$dirIterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$iterator = new \RecursiveIteratorIterator($dirIterator, \RecursiveIteratorIterator::SELF_FIRST);

$this->watchDir($dir);
foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if ($file->isDir()) {
$this->watchDir($file->getPathname());
}
}
}

$eventLoop->onReadable($this->fd, fn(string $id, mixed $fd) => $this->onNotify($eventLoop, $fd));
}

/**
* @param resource $inotifyFd
*/
private function onNotify(Driver $eventLoop, mixed $inotifyFd): void
{
/** @psalm-suppress RiskyTruthyFalsyComparison */
$events = \inotify_read($inotifyFd) ?: [];

if ($this->delayedRebootCallback !== null) {
return;
}

foreach($events as $event) {
if ($this->isFlagSet($event['mask'], IN_IGNORED)) {
unset($this->pathByWd[$event['wd']]);
continue;
}

if ($this->isFlagSet($event['mask'], IN_CREATE | IN_ISDIR)) {
$this->watchDir($this->pathByWd[$event['wd']] . '/' . $event['name']);
continue;
}

if (!$this->isPatternMatch($event['name'])) {
continue;
}

$this->delayedRebootCallback = function (): void {
$this->delayedRebootCallback = null;
$this->reload();
};

$eventLoop->delay(self::REBOOT_DELAY, $this->delayedRebootCallback);

return;
}
}

private function watchDir(string $path): void
{
$wd = \inotify_add_watch($this->fd, $path, IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVED_TO);
$this->pathByWd[$wd] = $path;
}

private function isFlagSet(int $check, int $flag): bool
{
return ($check & $flag) === $flag;
}
}
61 changes: 61 additions & 0 deletions src/FileMonitorWatcher/PollingMonitorWatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

declare(strict_types=1);

namespace Luzrain\PhpRunnerBundle\FileMonitorWatcher;

use Revolt\EventLoop\Driver;

/**
* @psalm-suppress PropertyNotSetInConstructor
*/
final class PollingMonitorWatcher extends FileMonitorWatcher
{
private const TO_MANY_FILES_WARNING_LIMIT = 1000;

private int $lastMTime = 0;
private bool $toManyFiles = false;

protected function __construct(private readonly float $pollingInterval)
{
}

public function start(Driver $eventLoop): void
{
$this->lastMTime = \time();
$eventLoop->repeat($this->pollingInterval, $this->checkFileSystemChanges(...));
$this->logger->notice('Polling file monitoring can be inefficient if the project has many files. Install the php-inotify extension to increase performance.');
}

private function checkFileSystemChanges(): void
{
$filesCout = 0;

foreach ($this->sourceDir as $dir) {
$dirIterator = new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS);
$iterator = new \RecursiveIteratorIterator($dirIterator, \RecursiveIteratorIterator::SELF_FIRST);

foreach ($iterator as $file) {
/** @var \SplFileInfo $file */
if ($file->isDir()) {
continue;
}

if (!$this->toManyFiles && ++$filesCout > self::TO_MANY_FILES_WARNING_LIMIT) {
$this->toManyFiles = true;
$this->logger->warning('There are too many files. This makes file monitoring very slow. Install php-inotify extension to increase performance.');
}

if (!$this->isPatternMatch($file->getFilename())) {
continue;
}

if ($file->getFileInfo()->getMTime() > $this->lastMTime) {
$this->lastMTime = $file->getFileInfo()->getMTime();
$this->reload();
return;
}
}
}
}
}
12 changes: 12 additions & 0 deletions src/Runner.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Luzrain\PhpRunner\PhpRunner;
use Luzrain\PhpRunnerBundle\Internal\Functions;
use Luzrain\PhpRunnerBundle\Worker\FileMonitorWorker;
use Luzrain\PhpRunnerBundle\Worker\HttpServerWorker;
use Luzrain\PhpRunnerBundle\Worker\ProcessWorker;
use Luzrain\PhpRunnerBundle\Worker\SchedulerWorker;
Expand Down Expand Up @@ -66,6 +67,17 @@ public function run(): int
));
}

if ($config['reload_strategy']['on_file_change']['active']) {
$phpRunner->addWorkers(new FileMonitorWorker(
sourceDir: $config['reload_strategy']['on_file_change']['source_dir'],
filePattern: $config['reload_strategy']['on_file_change']['file_pattern'],
pollingInterval: $config['reload_strategy']['on_file_change']['polling_interval'],
user: $config['user'],
group: $config['group'],
reloadCallback: $phpRunner->reload(...),
));
}

return $phpRunner->run();
}
}
55 changes: 55 additions & 0 deletions src/Worker/FileMonitorWorker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace Luzrain\PhpRunnerBundle\Worker;

use Luzrain\PhpRunner\WorkerProcess;
use Luzrain\PhpRunnerBundle\FileMonitorWatcher\FileMonitorWatcher;

final class FileMonitorWorker extends WorkerProcess
{
public function __construct(
private array $sourceDir,
private array $filePattern,
private float $pollingInterval,
string|null $user,
string|null $group,
private \Closure $reloadCallback,
) {
parent::__construct(
name: 'File monitor',
user: $user,
group: $group,
reloadable: false,
onStart: $this->onStart(...),
);
}

private function onStart(): void
{
$fileMonitor = FileMonitorWatcher::create(
$this->getLogger(),
$this->sourceDir,
$this->filePattern,
$this->pollingInterval,
$this->doReload(...),
);
$fileMonitor->start($this->getEventLoop());
}

/**
* @psalm-suppress NoValue
* @psalm-suppress RiskyTruthyFalsyComparison
*/
private function doReload(): void
{
($this->reloadCallback)();

if (\function_exists('opcache_get_status') && $status = \opcache_get_status()) {
foreach (\array_keys($status['scripts'] ?? []) as $file) {
\opcache_invalidate($file, true);
}
}
}
}
1 change: 0 additions & 1 deletion src/Worker/ProcessWorker.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ private function runExternalCommand(string $command): void
{
$this->detach();
$envVars = [...\getenv(), ...$_ENV];
unset($envVars['APP_RUNTIME']);
\str_ends_with($command, '.sh')
? \pcntl_exec($command, [], $envVars)
: \pcntl_exec('/bin/sh', ['-c', $command], $envVars)
Expand Down
1 change: 0 additions & 1 deletion src/Worker/SchedulerWorker.php
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ private function runExternalCommand(string $command, \Closure|null $onStdErrorCa
{
$cwd = $this->kernel->getProjectDir();
$envVars = [...\getenv(), ...$_ENV];
unset($envVars['APP_RUNTIME']);
$socketPair = \stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
\stream_set_blocking($socketPair[1], false);
$descriptorspec = [
Expand Down
5 changes: 5 additions & 0 deletions src/config/configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@
'*.yaml',
])
->end()
->floatNode('polling_interval')
->info('Interval in seconds between file system scans. Only affects in polling mode.')
->min(1.0)
->defaultValue(1.0)
->end()
->end()
->end()
->end()
Expand Down

0 comments on commit ec2ba91

Please sign in to comment.