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(HotReloadState::class)->store($changedFiles); 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; } }