diff --git a/Abstracts/FileWatcher.php b/Abstracts/FileWatcher.php new file mode 100644 index 0000000..b11aded --- /dev/null +++ b/Abstracts/FileWatcher.php @@ -0,0 +1,444 @@ +watchPaths = config('servers.reload.listen', []); + $this->excludePatterns = config('servers.reload.scan.skip_patterns', []); + $this->extensions = config('servers.reload.scan.extensions', ['php']); + + di(EventProvider::class)->on(OnWorkerStart::class, [di(Router::class), 'scan_build_route']); + + $this->strategy = $this->detectBestStrategy(); + } + + public function getName(): string + { + return 'hotReload'; + } + + public function onSigterm(): void + { + $this->stop(); + } + + private function detectBestStrategy(): string + { + $forced = config('servers.reload.scan.strategy', 'auto'); + if (in_array($forced, [self::STRATEGY_INOTIFY, self::STRATEGY_FSWATCH, self::STRATEGY_POLL], true)) { + return $forced; + } + + if ($this->isMountedWindowsPath()) { + return $this->isFswatchAvailable() ? self::STRATEGY_FSWATCH : self::STRATEGY_POLL; + } + + if (extension_loaded('inotify')) { + return self::STRATEGY_INOTIFY; + } + + if ($this->isFswatchAvailable()) { + return self::STRATEGY_FSWATCH; + } + + return self::STRATEGY_POLL; + } + + private function isMountedWindowsPath(): bool + { + foreach ($this->watchPaths as $path) { + $real = realpath($path) ?: (string)$path; + $real = str_replace('\\', '/', $real); + if (str_starts_with($real, '/mnt/')) { + return true; + } + } + + return false; + } + + private function isFswatchAvailable(): bool + { + $output = []; + $returnVar = 0; + exec('which fswatch 2>/dev/null', $output, $returnVar); + return $returnVar === 0 && !empty($output[0]); + } + + public function setDebounce(int $milliseconds): self + { + $this->debounceMs = $milliseconds; + return $this; + } + + public function setPollInterval(int $seconds): self + { + $this->pollInterval = $seconds; + return $this; + } + + public function process(Process|null $process): void + { + if ($this->running) { + return; + } + + $this->running = true; + + switch ($this->strategy) { + case self::STRATEGY_INOTIFY: + $this->startInotify(); + break; + case self::STRATEGY_FSWATCH: + $this->startFswatch(); + break; + case self::STRATEGY_POLL: + $this->startPolling(); + break; + } + + Event::wait(); + } + + public function stop(): void + { + $this->running = false; + + if ($this->inotifyFd && is_resource($this->inotifyFd)) { + @Event::del($this->inotifyFd); + @fclose($this->inotifyFd); + $this->inotifyFd = null; + } + + if (isset($this->fswatchPipes[1]) && is_resource($this->fswatchPipes[1])) { + @Event::del($this->fswatchPipes[1]); + @fclose($this->fswatchPipes[1]); + } + + if (isset($this->fswatchPipes[2]) && is_resource($this->fswatchPipes[2])) { + @fclose($this->fswatchPipes[2]); + } + + if ($this->fswatchProcess && is_resource($this->fswatchProcess)) { + @proc_terminate($this->fswatchProcess); + @proc_close($this->fswatchProcess); + $this->fswatchProcess = null; + } + + if ($this->pollTimer) { + \Swoole\Timer::clear($this->pollTimer); + $this->pollTimer = null; + } + + if ($this->debounceTimer) { + \Swoole\Timer::clear($this->debounceTimer); + $this->debounceTimer = null; + } + + @Event::exit(); + } + + private function isExcluded(string $path): bool + { + foreach ($this->excludePatterns as $pattern) { + if (strpos($path, $pattern) !== false) { + return true; + } + } + + return false; + } + + private function hasValidExtension(string $path): bool + { + if (empty($this->extensions)) { + return true; + } + + $ext = pathinfo($path, PATHINFO_EXTENSION); + return in_array($ext, $this->extensions, true); + } + + private function triggerCallback(array $changedFiles): void + { + $changedFiles = array_values(array_unique(array_filter($changedFiles, function ($file) { + return !$this->isExcluded($file) && $this->hasValidExtension($file); + }))); + + if (empty($changedFiles)) { + return; + } + + if ($this->debounceTimer) { + \Swoole\Timer::clear($this->debounceTimer); + } + + $this->debounceTimer = \Swoole\Timer::after($this->debounceMs, function () use ($changedFiles) { + $this->debounceTimer = null; + $this->reload($changedFiles); + }); + } + + private function reload(array $changedFiles): void + { + if ($this->reloading) { + return; + } + + $this->reloading = true; + + try { + $preview = implode(', ', array_slice($changedFiles, 0, 3)); + if (count($changedFiles) > 3) { + $preview .= ' ...'; + } + + di(StdoutLogger::class)->println('detected file changes, reloading server: ' . $preview); + + $server = di(ServerInterface::class); + if (method_exists($server, 'reload')) { + $server->reload(); + } + } finally { + $this->reloading = false; + } + } + + private function startInotify(): void + { + $this->inotifyFd = inotify_init(); + stream_set_blocking($this->inotifyFd, false); + + foreach ($this->watchPaths as $path) { + $this->addInotifyWatchRecursive($path); + } + + Event::add($this->inotifyFd, function ($fd) { + $events = inotify_read($fd); + if ($events === false) { + return; + } + + $changedFiles = []; + foreach ($events as $event) { + $wd = $event['wd']; + $dir = $this->inotifyWatchMap[$wd] ?? ''; + $name = $event['name'] ?? ''; + $filePath = $name === '' ? $dir : $dir . '/' . $name; + + if (($event['mask'] & IN_CREATE) && $filePath !== '' && is_dir($filePath) && !$this->isExcluded($filePath)) { + $this->addInotifyWatchRecursive($filePath); + } + + if ($filePath !== '') { + $changedFiles[] = $filePath; + } + } + + $this->triggerCallback($changedFiles); + }); + } + + private function addInotifyWatchRecursive(string $path): void + { + if ($this->isExcluded($path)) { + return; + } + + if (is_file($path)) { + if ($this->hasValidExtension($path)) { + $wd = inotify_add_watch($this->inotifyFd, $path, IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVE | IN_CLOSE_WRITE); + $this->inotifyWatchMap[$wd] = dirname($path); + } + return; + } + + if (!is_dir($path)) { + return; + } + + $wd = inotify_add_watch($this->inotifyFd, $path, IN_MODIFY | IN_CREATE | IN_DELETE | IN_MOVE | IN_CLOSE_WRITE); + $this->inotifyWatchMap[$wd] = $path; + + $items = scandir($path); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $full = $path . '/' . $item; + if (is_dir($full)) { + $this->addInotifyWatchRecursive($full); + } + } + } + + private function startFswatch(): void + { + $descriptorspec = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $excludeArgs = []; + foreach ($this->excludePatterns as $pattern) { + $excludeArgs[] = '--exclude'; + $excludeArgs[] = '.*' . preg_quote($pattern, '/') . '.*'; + } + + $filterArgs = []; + foreach ($this->extensions as $ext) { + $filterArgs[] = '--include'; + $filterArgs[] = '\.' . $ext . '$'; + } + + $cmd = 'fswatch -0 -r ' . implode(' ', array_merge($excludeArgs, $filterArgs)) . ' ' . implode(' ', array_map('escapeshellarg', $this->watchPaths)); + + $this->fswatchProcess = proc_open($cmd, $descriptorspec, $this->fswatchPipes); + if (!is_resource($this->fswatchProcess)) { + throw new \RuntimeException('Failed to start fswatch process'); + } + + stream_set_blocking($this->fswatchPipes[1], false); + + Event::add($this->fswatchPipes[1], function ($pipe) { + $data = fread($pipe, 8192); + if ($data === false || $data === '') { + return; + } + + $files = explode("\0", trim($data, "\0")); + $changedFiles = array_values(array_filter($files, function ($file) { + return $file !== ''; + })); + + $this->triggerCallback($changedFiles); + }); + } + + private function startPolling(): void + { + $this->buildFileSnapshot(); + + $intervalMs = $this->pollInterval * 1000; + $this->pollTimer = \Swoole\Timer::tick($intervalMs, function () { + $newSnapshot = []; + $changedFiles = $this->compareSnapshots($newSnapshot); + if (!empty($changedFiles)) { + $this->triggerCallback($changedFiles); + } + + $this->fileSnapshot = $newSnapshot; + }); + } + + private function buildFileSnapshot(): void + { + $this->fileSnapshot = []; + foreach ($this->watchPaths as $path) { + $this->scanDirectory($path, $this->fileSnapshot); + } + } + + private function scanDirectory(string $path, array &$snapshot): void + { + if ($this->isExcluded($path)) { + return; + } + + if (is_file($path)) { + if ($this->hasValidExtension($path)) { + $snapshot[$path] = filemtime($path); + } + return; + } + + if (!is_dir($path)) { + return; + } + + $items = scandir($path); + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $full = $path . '/' . $item; + $this->scanDirectory($full, $snapshot); + } + } + + private function compareSnapshots(array &$newSnapshot): array + { + $changed = []; + + foreach ($this->watchPaths as $path) { + $this->scanDirectory($path, $newSnapshot); + } + + foreach ($newSnapshot as $file => $mtime) { + if (!isset($this->fileSnapshot[$file])) { + $changed[] = $file; + } elseif ($this->fileSnapshot[$file] !== $mtime) { + $changed[] = $file; + } + } + + foreach ($this->fileSnapshot as $file => $mtime) { + if (!isset($newSnapshot[$file])) { + $changed[] = $file; + } + } + + return $changed; + } +} diff --git a/ServerCommand.php b/ServerCommand.php index c49b452..ef771ea 100644 --- a/ServerCommand.php +++ b/ServerCommand.php @@ -6,6 +6,7 @@ namespace Kiri\Server; use Exception; use Kiri; +use Kiri\Server\Abstracts\FileWatcher; use Kiri\Server\Events\OnWorkerStart; use Kiri\Events\EventProvider; use Kiri\Events\EventDispatch; @@ -133,7 +134,7 @@ class ServerCommand extends Command { $this->asyncServer->addProcess(config('process', [])); if (\config('servers.reload.hot', false) === true) { - $this->asyncServer->addProcess([HotReload::class]); + $this->asyncServer->addProcess([FileWatcher::class]); } else { di(Router::class)->scan_build_route(); }