Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/docker/ci/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ RUN set -eux; \
apt-get update; \
apt-get install --yes --no-install-recommends \
curl \
fswatch \
git \
libavif-dev \
libfreetype6-dev \
Expand Down Expand Up @@ -85,5 +86,6 @@ RUN set -eux; \
php -r '$extensions = ["bcmath", "ctype", "curl", "dom", "fileinfo", "filter", "gd", "gmp", "imagick", "intl", "mbstring", "openssl", "pcntl", "pdo", "pdo_mysql", "pdo_pgsql", "pdo_sqlite", "posix", "redis", "session", "sockets", "sqlite3", "swoole", "tokenizer", "zlib"]; foreach ($extensions as $extension) { if (! extension_loaded($extension)) { fwrite(STDERR, "Missing required extension: {$extension}\n"); exit(1); } } foreach (["imagewebp", "imageavif"] as $function) { if (! function_exists($function)) { fwrite(STDERR, "Missing required GD function: {$function}()\n"); exit(1); } } if (version_compare((string) phpversion("redis"), "6.3.0", "<")) { fwrite(STDERR, "Redis extension 6.3.0 or newer is required for full integration coverage.\n"); exit(1); } if ((bool) ini_get("swoole.use_shortname")) { fwrite(STDERR, "Swoole short names must be disabled.\n"); exit(1); }'; \
php -r 'foreach (["avif", "heic"] as $format) { $image = new Imagick; try { $image->newImage(1, 1, "white"); $image->setImageFormat($format); if ($image->getImageBlob() === "") { fwrite(STDERR, "Imagick produced an empty {$format} image.\n"); exit(1); } } finally { $image->clear(); $image->destroy(); } }'; \
composer --version --no-ansi; \
fswatch --version; \
git --version; \
ps --version

Large diffs are not rendered by default.

10 changes: 0 additions & 10 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,6 @@
- Correct `CompiledRouteCollection`'s 405 method aggregation when cached routes and routes added at runtime share a path but allow different methods. If the compiled matcher rejects the request method, the dynamic collection's `MethodNotAllowedHttpException` currently replaces the compiled matcher's allowed-method set, so the response's `Allow` header omits methods supplied by the cached routes. Laravel has the same catch structure, but Hypervel should retain, merge, and de-duplicate both method sets before producing the 405 response. Add focused coverage with cached and dynamic methods in both registration directions, including GET/HEAD behavior.
- Handle pathless absolute-form request targets in `RequestBridge`. Symfony leaves `http://example.com` as the request URI and derives `/http://example.com` as its path; with a query it can also append the query twice. The absolute-form grammar permits an empty path and servers must accept absolute-form requests ([RFC 9112 section 3.2.2](https://www.rfc-editor.org/rfc/rfc9112.html#section-3.2.2)), while an empty HTTP(S) path is normally equivalent to `/` except for OPTIONS ([RFC 9110 section 4.2.3](https://www.rfc-editor.org/rfc/rfc9110.html#section-4.2.3)). Normalize pathless HTTP(S) targets before Symfony derives the path, preserve the query exactly once, and add explicit GET and OPTIONS coverage alongside host, port, and query variants.

## Watcher

- Consolidate the two find-based watcher drivers while adding deletion detection:
- Keep the public `FindDriver` name, replace its rolling `find -mmin` implementation with `FindNewerDriver`'s alternating reference-file and `find -newer` design, then remove `FindNewerDriver`. Do not retain a compatibility alias or add a mode setting: the two implementations have different state and lifecycle requirements, and the older mode provides no useful capability worth exposing.
- [`find -newer` is part of the POSIX `find` surface](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/find.html), while `-mmin` is an extension. The current `FindDriver` also requires GNU `gfind` on macOS even though `FindNewerDriver` works with the system `find`. Hyperf [added `FindNewerDriver` specifically for macOS, Linux, and Docker compatibility](https://github.com/hyperf/hyperf/pull/3170) but retained the older driver; Hypervel does not need to carry both forward.
- Preserve the reference driver's correctness properties: create and own unique temporary reference files, record the next cutoff before scanning, advance the cutoff only after a successful scan, retain the last successful cutoff across failures, and clean up safely across stop and restart. This avoids the rolling window losing changes after a failed scan, avoids changes aging out because each polling delay begins only after the preceding scan completes, and avoids `FindDriver`'s whole-second `filemtime()` de-duplication missing another modification within the same second. It also removes the `-mmin` formatting failure where a positive `scan_interval` below roughly 300 ms becomes `-0.00` and matches nothing. Cover changes made during a slow scan and very small positive scan intervals. Both approaches traverse the same directory tree, so the old mode has no meaningful performance advantage. An unwritable temporary directory is the only realistic boundary where the old mode could start while the reference mode cannot, and that is not a useful development environment to support with another driver.
- Use unambiguous NUL-delimited path output for both change detection and inventory reconciliation, with an emission strategy supported by every target system `find`; do not retain the current `-print` plus newline-splitting protocol, which corrupts valid filenames containing embedded newlines. Cover newline-containing filenames in both watched directories and explicit file targets.
- Keep a lightweight inventory of matched paths, reconcile it during each successful scan, and report removed paths without hashing file contents. Cover file and directory deletion, renames, newly discovered paths, command failures, repeated lifecycle calls, stop during an active scan, and restart after cleanup. Migrate the useful `FindNewerDriver` coverage to `FindDriver` and remove tests that exist only for the discarded `-mmin` behavior.
- Update `config/watcher.php` and `watcher.md` to expose only `ScanFileDriver`, `FindDriver`, and `FswatchDriver`. Add a concise "Choosing a Driver" subsection after the driver table: recommend `ScanFileDriver` as the dependency-free and most portable default, while noting that hashing every watched file costs more polling I/O as the tree grows; recommend `FswatchDriver` for large trees on native filesystems when its dependency and operating-system event delivery are suitable, since it has the lowest steady-state work; and describe `FindDriver` as the Unix polling middle ground when `fswatch` is unavailable, using file metadata rather than reading and hashing file contents. Mention that polling is the safer choice where container, virtual-machine, or network mounts do not forward filesystem events reliably. Update the table's detected-change entry after deletion reconciliation is implemented rather than documenting the future behavior early.

## Documentation

- Publish reproducible Hypervel 0.4 benchmarks on a dedicated documentation page before linking them from the introduction. Record the framework, PHP, Swoole, and dependency versions; use the same hardware and load-generation conditions for every runtime; publish the benchmark applications and configuration; and include the raw results, collection date, and limitations. Do not reuse the Hypervel 0.3 results as current data. Once the page is published, add it to `src/docs/documentation.md` and link to it from the introduction.
Expand Down
46 changes: 35 additions & 11 deletions src/docs/watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,22 @@
- [Scan Interval](#scan-interval)
- [Server Command](#server-command)
- [Watcher Drivers](#watcher-drivers)
- [Choosing a Driver](#choosing-a-driver)
- [Custom Drivers](#custom-drivers)
- [Custom Restart Strategies](#custom-restart-strategies)
- [Credits](#credits)

<a name="introduction"></a>
## Introduction

Hypervel application workers remain in memory between requests, so changes to your application code are not loaded until the server restarts. During local development, you may use the file watcher to monitor your application and automatically restart the server when a watched file changes.
Because Hypervel application workers remain in memory between requests, changes to your application code are not loaded until the server restarts. During local development, you may use the file watcher to monitor your application and automatically restart the server when a watched file changes.

The watcher may also be used by other long-running processes. For example, [Hypervel Horizon](/docs/{{version}}/horizon#automatically-restarting-horizon) uses it to restart Horizon when your application changes.

<a name="running-the-watcher"></a>
## Running the Watcher

You may start the development server and watch your application using the `watch` Artisan command:
To start the development server and watch your application, invoke the `watch` Artisan command:

```shell
php artisan watch
Expand Down Expand Up @@ -91,7 +92,7 @@ return [
<a name="watch-paths"></a>
### Watch Paths

The `watch` option accepts paths relative to your application's base directory. Each entry may be a directory, a glob pattern, or a specific file:
The `watch` option accepts directories, glob patterns, and specific files relative to your application's base directory:

```php
'watch' => [
Expand All @@ -104,10 +105,23 @@ The `watch` option accepts paths relative to your application's base directory.
],
```

A directory entry watches every file within that directory recursively. A specific file entry only watches that exact file. Plain paths are identified as directories when the watcher starts; a plain path that is not an existing directory is treated as a file.
At least one path must be provided through the configuration or the command line. Paths may not be empty or absolute.

Use `.` to watch the application root. A path may also begin with `..` to watch a directory outside your application, such as a package you are developing alongside it. Redundant separators, trailing separators, and `.` path segments are ignored:

```php
'watch' => [
'./app',
'../packages/example/src/**/*.php',
],
```

A directory entry watches every file within that directory recursively, while a specific file entry watches only that file. When the watcher starts, it treats a plain path that names an existing directory as a directory. Otherwise, it treats the path as a file. If you create a configured directory after starting the watcher, restart the command so the path can be classified as a directory.

Glob patterns use Symfony Finder's glob syntax. A single `*` matches within one directory, while `**` may match across directories. You may also use `?` to match one character, braces to match one of several values, and brackets to match a character range.

A directory given directly as a watch path may be a symbolic link. The watcher follows that root, but does not traverse symbolic links found inside a watched directory.

<a name="scan-interval"></a>
### Scan Interval

Expand All @@ -119,7 +133,7 @@ The `scan_interval` option determines how often polling drivers check for change

The scan interval must be greater than zero.

This option is used by the `ScanFileDriver`, `FindDriver`, and `FindNewerDriver`. The `FswatchDriver` receives operating system events and does not use the scan interval.
This option is used by the `ScanFileDriver` and `FindDriver`. The `FswatchDriver` receives operating system events and does not use the scan interval.

<a name="server-command"></a>
### Server Command
Expand All @@ -140,19 +154,29 @@ Hypervel includes several drivers for detecting file changes:

| Driver | Requirements | Detected Changes |
|---|---|---|
| `ScanFileDriver` | None | Created, modified, and deleted files |
| `FindDriver` | `find`, or GNU `gfind` on macOS | Created and modified files |
| `FindNewerDriver` | `find` | Created and modified files |
| `ScanFileDriver` | None | Created, modified, renamed, and deleted files |
| `FindDriver` | `find` | Created, modified, renamed, and deleted files |
| `FswatchDriver` | `fswatch` | Created, modified, renamed, and deleted files |

The `ScanFileDriver` is the default and works by comparing file hashes at each scan interval. The `FindDriver` and `FindNewerDriver` use file modification times and do not detect deleted files. The `FswatchDriver` uses operating system file events instead of polling.

You may select a driver using the `driver` option in your `watcher.php` configuration file:

```php
'driver' => Hypervel\Watcher\Driver\FswatchDriver::class,
```

<a name="choosing-a-driver"></a>
### Choosing a Driver

The `ScanFileDriver` requires no external tools and works on every supported platform. It reads each matched file during every scan, allowing it to detect content changes even when a file's metadata does not change. However, this may result in more disk activity when watching large directory trees. If part of the watched tree becomes unreadable, its files are reported as removed and then added again when access returns.

The `FindDriver` uses your system's `find` executable and is a good polling choice on Unix systems. Since it checks filesystem metadata instead of reading file contents, it generally requires less disk activity than the `ScanFileDriver`. However, it cannot detect a rewrite that preserves the file's modification time. On filesystems that record modification times only to the nearest second, a rewrite may also be missed when its timestamp matches the time of the previous scan.

If `find` cannot finish listing the watched files, such as when a watched directory cannot be read, deletions are not reported until a later scan completes. If it also cannot finish checking for changes, changes that were already detected may be reported again until the filesystem error is fixed.

The `FswatchDriver` uses operating system events instead of repeatedly scanning your files, giving it the lowest steady-state resource usage on local filesystems. This driver requires the `fswatch` executable and depends on your operating system delivering file events. On Linux, Hypervel registers only the directories required by your watch patterns, reducing inotify usage. On macOS, each watch root is observed recursively, so you should avoid unnecessarily broad roots.

Polling is generally safer when files live in containers, virtual machines, or network mounts that do not reliably forward operating system events.

<a name="custom-drivers"></a>
### Custom Drivers

Expand All @@ -171,7 +195,7 @@ interface DriverInterface
}
```

The `watch` method should run the watch loop and push each changed file path into the provided channel. The `stop` method should release the driver's resources, unblock its watch loop, and safely handle repeated calls. Hypervel resolves the configured driver through the service container. The driver's constructor may accept the current `Hypervel\Watcher\Option` instance using an `$option` parameter, as well as any other dependencies it needs.
The `watch` method should run the watch loop and push each changed file path into the provided channel. The `stop` method should release the driver's resources, promptly unblock its watch loop, and safely handle repeated calls. After calling `stop`, Hypervel waits up to one second for `watch` to return before reporting an error. Hypervel resolves the configured driver through the service container. The driver's constructor may accept the current `Hypervel\Watcher\Option` instance using an `$option` parameter, as well as any other dependencies it needs.

Once you have implemented the driver, specify its class in your application's configuration:

Expand Down
2 changes: 0 additions & 2 deletions src/watcher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,3 @@ File Watcher for Hypervel
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/watcher)

Documentation: https://hypervel.org/docs/watcher

Ported from: https://github.com/hyperf/hyperf/tree/master/src/watcher
17 changes: 8 additions & 9 deletions src/watcher/config/watcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@
|
| The driver used to detect file changes. Available drivers:
|
| - ScanFileDriver: Hash polling; observes creation, modification, deletion.
| - FindDriver: Uses `find -mmin`; observes creation and modification.
| - FindNewerDriver: Uses `find -newer`; observes creation and modification.
| - ScanFileDriver: Content polling; observes creation, modification, deletion.
| - FindDriver: Metadata polling; observes creation, modification, deletion.
| - FswatchDriver: OS events; observes creation, modification, rename, deletion.
|
*/
Expand All @@ -27,8 +26,8 @@
|--------------------------------------------------------------------------
|
| How often the watcher polls for file changes, in milliseconds. This
| applies to all polling-based drivers (ScanFile, Find, FindNewer).
| The FswatchDriver uses OS-level events and ignores this setting.
| applies to the ScanFileDriver and FindDriver. The FswatchDriver uses
| OS-level events and ignores this setting.
|
*/

Expand All @@ -39,10 +38,10 @@
| Watch Paths
|--------------------------------------------------------------------------
|
| Paths and glob patterns to monitor for changes. Each entry can be
| a directory name (watches all files recursively), a glob pattern
| (watches matching files only), or a specific file path. See the
| Symfony Finder Glob documentation for supported pattern syntax.
| Relative paths and glob patterns to monitor for changes. Each entry can
| be a directory name (watches all files recursively), a glob pattern
| (watches matching files only), or a specific file path. See the Symfony
| Finder Glob documentation for supported pattern syntax.
|
*/

Expand Down
2 changes: 1 addition & 1 deletion src/watcher/src/Console/WatchCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public function __construct(protected Container $container)
{
parent::__construct('watch');
$this->setDescription('Watch for file changes and automatically restart the server.');
$this->addOption('path', 'P', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Additional paths to watch', []);
$this->addOption('path', 'P', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Additional paths to watch', []);
$this->addOption('no-restart', 'N', InputOption::VALUE_NONE, 'Detect changes without restarting the server');
}

Expand Down
43 changes: 40 additions & 3 deletions src/watcher/src/Driver/AbstractDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,26 +41,42 @@ public function stop(): void
$this->stopSignal?->close();
}

/**
* Determine whether the driver has been stopped.
*
* The state may change while hooked I/O yields to another coroutine.
*
* @phpstan-impure
*/
protected function isStopping(): bool
{
return $this->stopping;
}

/**
* Run a polling scan until the driver is stopped.
*/
protected function watchAtInterval(float $seconds, callable $scan): void
{
if ($this->stopping) {
if ($this->isStopping()) {
return;
}

$stopSignal = $this->stopSignal = new Channel(1);

try {
while (true) {
$scan();

if ($this->isStopping()) {
return;
}

$signal = $stopSignal->pop($seconds);

if ($signal !== false || ! $stopSignal->isTimeout()) {
return;
}

$scan();
}
} finally {
if (! $stopSignal->isClosing()) {
Expand Down Expand Up @@ -91,6 +107,27 @@ static function (WatchPath $watchPath): string {
);
}

/**
* Group watch paths by their resolved target.
*
* @param list<WatchPath> $watchPaths
* @return array<string, array{recursive: bool, watchPaths: list<WatchPath>}>
*/
protected function groupWatchPathsByTarget(array $watchPaths): array
{
$targets = $this->resolveTargets($watchPaths);
$groups = [];

foreach ($watchPaths as $index => $watchPath) {
$target = $targets[$index];
$groups[$target] ??= ['recursive' => false, 'watchPaths' => []];
$groups[$target]['recursive'] = $groups[$target]['recursive'] || $watchPath->recursive;
$groups[$target]['watchPaths'][] = $watchPath;
}

return $groups;
}

/**
* Filter targets that currently exist.
*
Expand Down
Loading