diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index a2719f5284..ce99a28266 100644 --- a/.github/docker/ci/Dockerfile +++ b/.github/docker/ci/Dockerfile @@ -17,6 +17,7 @@ RUN set -eux; \ apt-get update; \ apt-get install --yes --no-install-recommends \ curl \ + fswatch \ git \ libavif-dev \ libfreetype6-dev \ @@ -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 diff --git a/docs/plans/2026-08-25-0001-components-watcher-remediation-plan-codex.md b/docs/plans/2026-08-25-0001-components-watcher-remediation-plan-codex.md new file mode 100644 index 0000000000..5c49a66dff --- /dev/null +++ b/docs/plans/2026-08-25-0001-components-watcher-remediation-plan-codex.md @@ -0,0 +1,547 @@ +# Hypervel Watcher Remediation Plan + +Status: Implementation, verification, documentation, and code review complete + +## Objective + +Make the watcher package correct, portable, and cheap enough to run beside several development applications without needless CPU, disk I/O, memory, subprocess, temporary-file, or inotify-descriptor use. + +This slice closes audit findings 105–111 and the Watcher section of `docs/todo.md`. It also fixes defects found while tracing the same code paths: invalid watch configuration can silently select the whole project, a bare `--path` reaches a raw type error, polling waits before establishing its baseline, `ScanFileDriver` materializes and sorts every discovered file, unreadable watched roots or child subtrees abort its scan, `FswatchDriver` corrupts newline-containing paths and applies one recursive setting to unrelated shallow roots, and `ServerRestartStrategy` mishandles absent and failed process signals. + +The package remains Laravel-style at its public surface: + +- `FindDriver`, `ScanFileDriver`, `FswatchDriver`, `Option`, and `WatchPath` keep their current public names and roles. +- `FindNewerDriver` is removed. It is a second Hypervel/Hyperf-era implementation of the same capability, not a Laravel API or a useful compatibility surface. +- Custom drivers continue to implement `DriverInterface` and receive `Option` through the container. +- No compatibility alias, driver mode, retry policy, tuning setting, or new public filesystem API is added. + +## Verified baseline + +| Area | Current behavior | Required result | +|---|---|---| +| Glob roots | `Option::parseGlob()` keeps the filename prefix before a wildcard, so `app/Foo*.php` becomes the nonexistent target `app/Foo` and `.env*` becomes `.env`. | Resolve the existing directory before the wildcard: `app` and `.` respectively. | +| Invalid paths | An empty watch entry becomes the application root and matches everything; a missing/empty watch list silently watches nothing; leading-slash paths are treated as if relative. | Reject empty entries, absolute entries, and an empty combined config/CLI list. Keep `.` as the explicit project-root form. | +| CLI paths | `--path` accepts no value and passes `null` into strict path normalization, producing a raw `TypeError`. | Require a value at the Symfony Console definition so the parser reports the invalid option before command execution. | +| Poll timing | `AbstractDriver::watchAtInterval()` waits one interval before its first scan. | Scan immediately to establish a baseline, then wait between scans. | +| Find portability | `FindDriver` uses non-portable `-mmin`, requires GNU `gfind` on macOS, rounds short intervals to `-0.00`, and deduplicates with PHP's whole-second `filemtime()`. | Use the system `find`, portable `-newer`, unique reference files, and no PHP mtime map. | +| Find correctness | Newline output corrupts valid paths; deletions are invisible; a failed scan can lose later changes or repeat data without a clear contract. | NUL-safe parsing, exact matched-path inventory, explicit partial-failure semantics, and cutoff rotation only after complete changed traversals. | +| ScanFile cost | `Filesystem::allFiles()` sorts and materializes every file once per configured directory, repeats overlapping identical roots, and then hashes matches. | Stream an unsorted Symfony Finder once per unique target and hash each matched path once. | +| ScanFile resilience | Opening an unreadable root can throw `UnexpectedValueException` and end the watcher; unreadable child subtrees can also abort discovery. | Skip unreadable roots and child subtrees while preserving other watched roots and readable siblings. | +| Fswatch protocol and registration | Both the command and parser are newline-delimited even though newlines are valid in filenames; macOS and Linux request different event sets; one global recursive flag makes the default exact `.env` target register the whole project tree on Linux. | Use one incremental NUL protocol, the same event filter, and separate shallow/recursive Linux process groups read by one flat loop. | +| Restart settings | A shallow replacement of `server.settings` can make the typed daemonize lookup fail; stop output is printed without a PID; native `posix_kill()` false is ignored. | Default daemonize to false, print only for a current PID, re-read after yielding output, and report false or thrown signal failures. | + +Audit mapping: + +- 105 is fixed by the corrected glob base and shared traversal-depth fact. +- 106 is superseded by the reference cutoff recorded before each scan; elapsed-time/minute rounding is removed with `-mmin`. +- 107 and the valid portion of 108 are direct `ServerRestartStrategy` fixes. +- 109 remains deliberately unchanged: full-content hashing is `ScanFileDriver`'s correctness contract. +- 110 is fixed by explicitly including hidden files in the streamed Finder. +- 111 disappears with the obsolete mtime map; there is no historical per-path state to prune. + +## Research and cost evidence + +- POSIX `find -newer reference` avoids the GNU-only `-mmin` path. POSIX Issue 8 also standardizes `-print0`. The direct-depth bound, `-maxdepth 1`, remains a GNU and BSD/Apple extension supported by the target systems for this package. +- `Swoole\Coroutine\System::exec()` invokes `/bin/sh -c`. Its POSIX `command -v` builtin probes the same command environment without depending on an external `which` executable or spawning it. +- The filesystem stores sub-second mtimes, but PHP's `filemtime()`, `stat()['mtime']`, and `SplFileInfo::getMTime()` expose whole seconds. A retained PHP mtime map therefore cannot detect two writes within one second and must not return in the consolidated driver. +- `Filesystem::allFiles()` delegates to `files()` with `sortByName()` and `iterator_to_array()`. Measured on 20,000 files: + + | Scan shape | Wall time | Peak memory | + |---|---:|---:| + | Existing sorted/materialized helper | about 537 ms | about 169 MB | + | Unsorted but materialized | about 252 ms | about 165 MB | + | Streamed Symfony Finder | about 118 ms | about 4 MB | + + Hashing all 20,000 files with `xxh128` took about 232 ms. Full hashing remains the driver's correctness guarantee; the avoidable work is sorting, materialization, and duplicate traversal. +- For 80,000 `find` candidates with 20,000 retained matches, cursor parsing with inline matching took about 14.5 ms and 3.1 MB of transient peak memory. `explode()` followed by filtering took about 19.1 ms and 9.3 MB. The cursor avoids about 6.2 MB per watcher without adding an abstraction. +- Current fswatch inotify source confirms that a watched directory reports events for first-level children without recursive registration. Recursive mode adds watches for descendant directories. FSEvents is recursive regardless of the flag. +- A direct file operand misses an atomic replacement event but reports later edits after the monitor re-registers the new inode during its next root scan. The replacement event is therefore the only observation that distinguishes a direct file operand from the required parent-directory operand. +- On a 1,028-directory application-shaped fixture, the shipped default's one recursive Linux process registered 1,028 inotify watches. Splitting the exact root file into a shallow process and `app`/`config` into a recursive process registered 26 in total. The current components checkout contains more than 19,000 directories, so this is a default-path resource defect rather than a tuning edge case. +- Fswatch has one recursive setting per monitor, not per operand. Installed 1.14.0 happens to use ordinary output filters while registering inotify watches, while current upstream uses a separate prune-filter collection. A filter-based depth emulation is therefore version-dependent and would add a second path matcher. +- Fswatch 1.14.0 canonicalizes existing command operands once at startup but retains the literal spelling of missing operands. Current upstream makes operands absolute without canonicalizing them. Passing canonical existing operands and literal missing operands from Hypervel gives both versions one stable output-prefix contract. +- Both installed and current inotify monitors retry missing root operands on each loop. A missing configured base can therefore become active without restarting; it must retain a literal matcher mapping from startup. +- All three driver primitives follow a symlink supplied as a root operand but do not follow symlinked directories found during recursive descent. An initially missing direct fswatch operand that later becomes a symlink is followed and continues to emit its literal startup spelling. Operand pruning must preserve both facts. +- Hooked `stream_select()` yields correctly over multiple process pipes. Closing a selected pipe from another coroutine can leave it blocked and produces Swoole reactor warnings with a timeout; sending `SIGKILL` to the direct child while the owner retains the read pipe wakes it cleanly through EOF. +- Current fswatch source maps `Created`, `Updated`, `Removed`, and `Renamed` on FSEvents as well as inotify. `-E` clears filters before any are installed, so it is inert in the current Linux command. + +These figures guide the design and will be re-measured during implementation. They are not timing assertions for CI. + +## Design invariants + +1. A polling cycle never misses a change merely because the preceding traversal was slow. +2. A failed changed traversal never advances the authoritative cutoff. Already discovered true positives may repeat; repeat suppression is not allowed to hide a second same-path change. +3. Deletions come only from a complete current inventory. Partial inventory output can never remove retained paths. +4. Before the first complete inventory, untouched newly inventoried paths establish the baseline without an addition flood; paths also proved changed are emitted. A retained path was individually proven live and may still produce a deletion on that first complete inventory. +5. Driver state is operation-local to one driver instance and naturally bounded: two reference files plus the currently retained matched live-path set. No worker-static or coroutine-scoped watcher state is needed. +6. Glob matching remains owned by `WatchPath` and Symfony's `Glob`; drivers only choose the minimum safe traversal depth and apply the existing matcher. +7. `ScanFileDriver` still detects exact content changes by hashing every matched file. It does not substitute metadata, periodic rehashing, or a tuning threshold for correctness. +8. Each configured path is interpreted relative to `base_path()`. An absolute-looking entry is rejected instead of being silently rebased under the application by `join_paths()`. +9. Stop remains terminal and idempotent for a driver instance. The normal `Watcher`/`WatchCommand` lifecycle does not restart one stopped driver instance. +10. On Linux, each operand receives only the recursion depth its configured paths require. At most two direct fswatch children are read by one coroutine; no child coroutine or pipe-multiplexer protocol is needed. +11. Every driver follows a symlink supplied as a configured root but does not follow symlinked directories found during recursive descent. + +## Implementation + +### 1. Validate and classify watch paths + +Update `src/watcher/src/Option.php`: + +- Merge configured `watch` entries with CLI `--path` entries and reject an empty combined list with `InvalidArgumentException`. +- Validate and normalize each raw entry once before wildcard detection or filesystem probing: reject `''` and leading `/`; remove trailing separators; split on `/`; discard empty and exact `.` segments wherever they occur; preserve every `..` segment; join the remaining segments with one `/`. When the entry consists only of root-dot segments, canonicalize it to `.` rather than rejecting it. Then deduplicate the normalized strings before constructing `WatchPath` values. This keeps traversal bases, public paths, and Symfony glob patterns on the same canonical form without resolving legitimate sibling paths such as `../packages/foo`. +- Keep `.` as the deliberate application-root entry. +- Define the command's repeatable `--path` option as `VALUE_REQUIRED | VALUE_IS_ARRAY`. A bare option has no meaning and must receive Symfony Console's native missing-value error; an explicit empty value continues through normal path validation. +- Fix `parseGlob()` by finding the first wildcard and truncating its preceding normalized literal prefix at the last slash: + + ```php + $prefix = substr($glob, 0, $wildcardPosition); + $slashPosition = strrpos($prefix, '/'); + $baseDirectory = $slashPosition === false ? '.' : substr($prefix, 0, $slashPosition); + ``` + + Preserve `.` when the slash is absent. + +Update `src/watcher/src/WatchPath.php`: + +- Add a computed public readonly `bool $recursive`, consistent with the existing public readonly `path`, `type`, and `pattern` facts. +- Exact files are not recursive. +- Plain directories are recursive. +- Derive a glob's suffix explicitly. Use the complete pattern for a `.` base; otherwise remove the normalized base and its first separator: `$suffix = $path === '.' ? $pattern : substr($pattern, strlen($path) + 1)`. A glob is recursive when this suffix contains `/` or `**`. The explicit `**` check matters because Symfony's `app/**` regex matches deep descendants even though its suffix contains no slash. +- Keep `matches()` and its Symfony regex as the sole semantic matcher. Do not build an exact glob-depth parser. + +Required examples: + +| Entry | Base | Recursive | +|---|---|---:| +| `.env*` | `.` | no | +| `app/Foo*.php` | `app` | no | +| `app//*.php` → `app/*.php` | `app` | no | +| `./app/*.php` → `app/*.php` | `app` | no | +| `app/./*.php` → `app/*.php` | `app` | no | +| `routes/?.php` | `routes` | no | +| `config/{app,queue}.php` | `config` | no | +| `app/*/Actions/*.php` | `app` | yes | +| `app/**` | `app` | yes | +| `app/**/*.php` | `app` | yes | +| `app` | `app` | yes | +| `app/` → `app` | `app` | yes | +| `./` → `.` | `.` | yes | +| `.env` | `.env` | no | + +### 2. Make polling establish its baseline immediately + +Update `AbstractDriver::watchAtInterval()` to execute the scan before waiting: + +```php +while (true) { + $scan(); + + if ($this->stopping) { + return; + } + + $signal = $stopSignal->pop($seconds); + + if ($signal !== false || ! $stopSignal->isTimeout()) { + return; + } +} +``` + +Keep the existing exception-safe channel cleanup. Cover: + +- immediate first scan; +- the first post-baseline change being detected within one interval, not two; +- stop before watch performs no scan; +- stop during a yielding scan prevents another wait/scan; +- scan exceptions still clean the signal channel. + +Do not add a concurrent-watch guard or retry loop. Concurrent `watch()` calls on one driver are not a supported or reachable `Watcher` lifecycle, and stop is terminal. + +### 3. Consolidate the find drivers + +Replace `src/watcher/src/Driver/FindDriver.php` with the final reference-file design and delete `src/watcher/src/Driver/FindNewerDriver.php`. + +#### Lifecycle and cutoff ownership + +- Probe `command -v find` in the constructor and use its exit status. Remove the external `which` dependency, `gfind`, GNU version probing, fractional-minute state, `startTime`, and the mtime map. +- Replace the inherited fragment with the grammatical, portable error ``The FindDriver requires the `find` executable.`` when the probe fails. +- Create two unique files with `tempnam()` only when an active `watch()` lifecycle begins. If creation fails partway, remove any file already created and throw. +- Remove both references from the outer `watch()` `finally`, whether polling returns or throws. +- Report reference cleanup failures through the driver's injected logger rather than PHP's process-global error log. +- `stop()` only signals the inherited polling loop. It does not unlink a reference that an active `System::exec()` may still be reading. +- If stop arrives while a scan is yielding, finish ownership of the active command but do not publish its output or rotate the reference afterward. The outer `finally` removes the references once the scan callback returns. +- Do not retain `FindNewerDriver::$scanning`, its deferred cleanup path, or its same-instance restart exception. They protect an unsupported restart shape and add lifecycle state without improving the actual caller. +- Before each scan, touch the inactive reference. Search relative to the active reference. Swap reference roles only when every changed traversal succeeded, including quiet successful scans. +- Inventory success is independent: an inventory failure suspends reconciliation but does not prevent a successful changed traversal from advancing the cutoff. +- One shared reference pair serves both depth groups. Per-group pairs add temp files and rotation state for a rare failure without improving normal detection. + +A process forcibly abandoned after `Watcher`'s bounded cleanup wait may leave two zero-byte temp files if its `find` subprocess is permanently hung. Do not add leases, shutdown registries, stale-file scans, or unsafe unlinking during active execution for that exceptional process-lifecycle failure. + +#### Target grouping and commands + +Use one `AbstractDriver::groupWatchPathsByTarget()` helper for Find and ScanFile's identical literal-target grouping. It returns each resolved absolute target with its combined recursion requirement and contributing watch paths. Fswatch remains bespoke because it groups by canonical identity when available and literal identity otherwise. + +For Find, group each shared-helper target by its required traversal depth: + +- identical targets are scanned once; +- recursive wins when any matcher for that target is recursive; +- exact files join the direct group; +- targets are rechecked with `existingTargets()` each cycle so disappearance and later creation are observable; +- do not attempt to merge different contained roots. +- Return the scan facts as a keyed array (`files`, `changedComplete`, `inventoryComplete`, and `failureCode`) so the adjacent completion flags cannot be transposed by an override. + +For each nonempty group, execute changed first and inventory second: + +```text +find -H -maxdepth 1 -newer -type f -print0 +find -H -maxdepth 1 -type f -print0 +``` + +Place `-H` before the operands and omit `-maxdepth 1` for the recursive group. `-H` follows only symlinks named as operands, not symlinks found during descent, matching Symfony Finder's default treatment of a symlinked root without enabling `followLinks()`. Escape every target and reference with the existing `shellArguments()` helper. This is at most four short-lived `find` subprocesses per cycle: changed and inventory for at most two groups. + +Do not combine the passes with an external `printf` tag protocol. It adds subprocesses and parsing states. Do not replace `System::exec()` with `proc_open()` streaming; buffering remains a cost, but streaming adds pipe/process ownership while inventory still has to retain the matched live set. + +#### NUL parsing and matching + +Use one small method for the two output consumers because both changed and inventory output need identical parsing and matching: + +- walk the output with `strpos($output, "\0", $offset)`; +- ignore an unterminated tail from a killed/failed command; +- convert each complete absolute path to a base-relative path; +- test every record against the complete configured watch-path list and stop at the first match; `find` output does not identify its originating operand, and matcher partitioning adds attribution logic for no meaningful saving; +- add a matched absolute path to `array` and stop after the first matcher; +- never construct an exploded list or all-candidate set. + +This cursor is deliberate hot-path code, not a parser abstraction. Add a short comment explaining that inline filtering avoids retaining every candidate path emitted by a broad `find` target. + +#### Inventory reconciliation + +Retain: + +```php +/** @var array */ +protected array $inventory = []; + +protected bool $hasCompleteInventory = false; +``` + +When every inventory traversal completes: + +```php +$additions = array_diff_key($currentInventory, $this->inventory); + +if (! $this->hasCompleteInventory) { + $additions = array_intersect_key($additions, $changedFiles); +} + +$deletions = array_diff_key($this->inventory, $currentInventory); +$modifications = array_diff_key( + array_intersect_key($changedFiles, $currentInventory), + $additions, +); +``` + +- While `$hasCompleteInventory === false`, retain only additions also proved by the changed traversal. This suppresses untouched pre-existing files while preserving a file genuinely created or modified after the reference cutoff during the first complete cycle. +- Emit deletions even on that first complete inventory. Every retained pre-baseline key came from changed output and was individually proven live, so its absence is a real deletion. +- Emit each addition, deletion, and modification once. +- Publish the current inventory and set the flag true. + +When any inventory traversal is incomplete: + +- publish the matched changed set directly; do not intersect it with a partial inventory; +- merge changed paths into the retained inventory as known-live entries; +- emit no deletions and do not replace the inventory; +- leave `$hasCompleteInventory` unchanged. + +An empty target set is a successful empty inventory. It reports deletion of retained paths after a target disappears and lets a target that later appears enter as additions after the baseline exists. + +#### Failure behavior + +- A partial changed traversal may publish paths already found, but any changed-traversal failure holds the old reference so those paths remain eligible on the next cycle. +- An inventory-only failure suspends deletions for that cycle but does not force changed paths to repeat because the complete changed traversal advances the cutoff. +- Log one warning per degraded cycle with the first nonzero exit code. The text must describe the guarantees actually affected: inventory failure suspends deletion detection; changed failure can repeat detected changes until the filesystem error is fixed. Combine both clauses when both fail. +- Do not suppress repeated sets. That would miss a real second edit to the same path during the degraded window. +- A creation after the changed traversal but before inventory appears as an addition and remains newer than the already-recorded next cutoff for the next cycle. It may therefore be reported once more on the next cycle. Suppressing that overlap without a content read or portable sub-second mtime would hide a genuine second edit, so correctness wins over eliminating this narrow duplicate. +- A path changed and then deleted before inventory is emitted only as a deletion, not as a modification and deletion. + +The user documentation must state that incomplete inventory suspends deletion detection. When the changed traversal also fails, already detected changes can be reported again on every interval; with restart enabled, that can restart the watched process repeatedly until the terminal filesystem error is fixed. + +### 4. Stream `ScanFileDriver` snapshots + +Update `src/watcher/src/Driver/ScanFileDriver.php`: + +- Accept `Option $option` without redeclaring the protected property already owned by `AbstractDriver`; keep the logger protected, consistent with `FindDriver` and the package's open extension/test surface. +- Import and use `Symfony\Component\Finder\Finder` directly; it is already a direct watcher dependency. +- Reuse `AbstractDriver::groupWatchPathsByTarget()` for unique resolved directory targets and their contributing matchers. Recursive wins for an identical target. Do not optimize containment among different roots. +- For each existing target, build an unsorted streaming finder: + + ```php + $finder = Finder::create() + ->files() + ->ignoreDotFiles(false) + ->ignoreUnreadableDirs() + ->in($target); + + if (! $recursive) { + $finder->depth(0); + } + ``` + +- Preserve Finder's default VCS-directory exclusion. +- Iterate it directly. Convert each candidate to the base-relative path, stop after its first matching `WatchPath`, and hash it once. +- Keep the injected `Filesystem` only for `hash()`; do not add a special walker or new `Filesystem` API. +- Continue hashing explicit file targets. Skip an explicit-path hash when the same absolute path was already collected by a directory scan. +- Let `Finder::in()` own missing-root detection instead of checking `is_dir()` first, removing the disappearance race between probing and opening the root. Catch `DirectoryNotFoundException|UnexpectedValueException`: the latter covers unreadable roots and Symfony's `AccessDeniedException`, while `ignoreUnreadableDirs()` lets readable child subtrees continue. Paths under an unreadable root or child disappear from the snapshot and emit deletions, then additions when access returns. This is finite and self-healing, so no degraded-mode state or second traversal is warranted. + +Simplify `processFileHashes()`: + +- once a prior snapshot exists, compute additions, deletions, and modified paths directly; +- log and emit only when any set is nonempty; +- remove the order-sensitive whole-array inequality guard. + +The unconditional diffs add about 0.35 ms at 20,000 entries compared with the old two-stage guard, while the scan costs tens or hundreds of milliseconds. The simpler logic also remains correct when iterator ordering changes. + +Do not add an mtime/size prescreen, periodic full rehash, configurable cap, or sampling. Those mechanisms either miss exact-content changes or delay them and add maintenance state. + +### 5. Make `FswatchDriver` NUL-safe and resource-aware + +Update `src/watcher/src/Driver/FswatchDriver.php` to use one process on Darwin and at most two process groups on Linux: shallow operands without `-r`, and recursive operands with `-r`. Read every stdout pipe in one flat `stream_select()` loop. + +Accept `Option $option` without redeclaring the inherited protected property. Probe `command -v fswatch` and use its exit status rather than depending on external `which`. Replace the inherited fragment with the grammatical, portable error ``The FswatchDriver requires the `fswatch` executable.`` when the probe fails. + +Build the common command with: + +```text +fswatch -0 --format %p --event Created --event Updated --event Removed --event Renamed +``` + +- Linux adds `-m inotify_monitor`; Darwin keeps one process because FSEvents observes each root recursively regardless of `-r`. +- Build one operand record per configured base. Exact files use their parent directory so an editor's atomic temp-file rename is observable; a direct file watch misses the replacement event itself. Directory and glob entries use their configured base. +- Retain the normalized absolute spelling for every operand. If it exists, use `realpath()` as both its command operand and output prefix. If it is missing, use its literal absolute spelling as both; fswatch retries missing roots and emits that startup spelling when they later appear. +- Deduplicate records by canonical identity when available and literal identity otherwise. When records share an identity, recursive wins, matching `FindDriver`'s grouping rule. Retain one matcher mapping per output prefix and configured base so aliases reconstruct every configured spelling before the complete ordered `WatchPath` list is applied. +- On Linux, place aggregated operands into shallow or recursive groups. Test only shallow operands against recursive operands; fswatch already deduplicates overlapping roots inside one recursive process, while nested shallow operands observe different directory levels and must both remain. +- Remove a distinct shallow command operand only when both it and a recursive operand exist and the shallow canonical path is equal to or component-nested beneath the recursive canonical path. Canonical containment proves physical containment because `realpath()` resolves symlinks and parent segments. Literal containment is unsafe: a lexically nested path may resolve through a symlink outside the recursive tree, and a missing path may later become such a symlink. +- Retain every distinct missing shallow operand. Its literal command operand and literal matcher prefix are load-bearing together: fswatch can activate it after it appears as a symlink outside the recursive tree, where the recursive process emits nothing. The rare regular-directory case may produce duplicate native records rather than risk silent event loss. +- Keep `isContainedBy($path, $parent)` on `FswatchDriver`, require canonical inputs, and use equality plus a separator-boundary prefix check. Do not add ancestor inspection or prospective canonicalization; no startup fact can prove what a missing final component will become. +- Retain the one shared matcher-entry list unchanged when a proven-contained shallow command operand is removed, so recursive output can still reconstruct every configured spelling. +- Spawn only nonempty groups. Darwin puts every operand in one group; Linux creates at most two children. +- Keep `proc_open()`'s argument-list form. This bypasses a shell and is required for shutdown: `stop()` must signal the direct process that holds stdout's write end so the reader receives EOF. +- Remove inert `-E`. + +Replace newline parsing with incremental NUL parsing, one buffer per stdout pipe: + +- preserve a partial record across `fread()` calls; +- publish only complete NUL-terminated paths; +- allow embedded newlines unchanged; +- do not publish an unterminated tail at EOF; +- keep unexpected EOF as a runtime failure; +- preserve path filtering, ordered publication, stop behavior, and exception-safe process/pipe cleanup. + +Use `stream_select()` with no timeout over the active stdout pipes. Check stop/channel state immediately before and after it yields, then read every ready pipe. If one child exits unexpectedly, throw and let the shared cleanup terminate its sibling. + +Resource ownership is deliberate: + +- `stop()` marks the driver stopped and sends `SIGKILL` to each live direct child. It does not close a pipe selected by the watch coroutine. +- The active `watch()` `finally` owns pipe closure and `proc_close()`. Remove each shared handle before performing a potentially yielding close so a concurrent `stop()` cannot signal a handle already being released. +- A normal stop wakes `stream_select()` through child EOF, observes the stopped state, and returns without treating that EOF as failure. + +Do not add reader coroutines, a `WaitGroup`, a shared failure slot, a wake pipe, timeout polling, or a record-tagging protocol. The one select loop has the same error semantics for one or two children with less lifecycle state. + +### 6. Correct server restart settings and signal handling + +Update `src/watcher/src/ServerRestartStrategy.php`: + +- Read `server.settings.daemonize` with `boolean(..., false)`. Hypervel shallow-replaces nested settings arrays, and false is both Swoole's default and the required foreground mode. +- In `terminateServer()`, return immediately when there is no published PID so no false `Stop server...` line is printed. +- The output call may yield. After it returns, re-read `$this->processId`; if it is null, return. Capture that current PID and perform no yielding work before signalling it. +- Treat `signalProcess(...) === false` exactly like a thrown `Throwable` and print `Stop server failed.`. +- Preserve the existing best-effort output handling: output failures must not prevent lifecycle cleanup. + +Cover the subtle replacement ordering: output yields, the prior child exits and a new PID is published, and the strategy signals only the currently owned PID rather than the stale one. + +### 7. Remove stale surfaces and update canonical documentation + +Update `src/watcher/config/watcher.php` and `src/docs/watcher.md`: + +- list only `ScanFileDriver`, `FindDriver`, and `FswatchDriver`; +- remove all `FindNewerDriver`, `gfind`, and `-mmin` guidance; +- describe polling interval use for the two polling drivers; +- state that watch paths are relative to the application, repeated/trailing separators and exact `.` segments are normalized while `..` is preserved, empty and absolute entries are invalid, and at least one configured or command-line entry is required; +- state that every driver follows a symlink supplied as a watch root but does not traverse symlinks encountered inside a watched directory; +- add a concise “Choosing a Driver” subsection: + - `ScanFileDriver` is dependency-free and most portable, detects exact content changes and add/modify/delete, but reads and hashes all matched files each cycle; + - `FindDriver` is the Unix polling middle ground, avoids content reads and detects add/modify/delete through metadata plus inventory, but exact preserved-mtime rewrites can be missed and coarse whole-second filesystems can miss a rewrite whose mtime equals the cutoff; + - `FswatchDriver` has the lowest steady-state work on native filesystems, but needs fswatch and depends on OS event delivery; Linux registers only the depth each configured root needs, while macOS receives each watched root recursively and filters unmatched events in PHP; + - polling is safer when containers, VMs, or network mounts do not forward filesystem events reliably; + - explain Find's degraded traversal behavior and ScanFile's unreadable-subtree remove/re-add behavior without implementation jargon. +- Keep `src/watcher/README.md` minimal and remove its `Ported from` line. The package no longer tracks Hyperf; its historical lineage remains in the canonical documentation's Credits section. +- Remove the completed Watcher section from `docs/todo.md` rather than leaving stale tasks. +- Do not add a porting-guide entry. Watcher is Hypervel-owned and this work does not change a Laravel API that porters need to adapt. + +## File plan + +### Production and configuration + +- Modify `src/watcher/src/Console/WatchCommand.php`. +- Modify `src/watcher/src/Option.php`. +- Modify `src/watcher/src/WatchPath.php`. +- Modify `src/watcher/src/Driver/AbstractDriver.php`. +- Rewrite `src/watcher/src/Driver/FindDriver.php` around the consolidated design. +- Delete `src/watcher/src/Driver/FindNewerDriver.php`. +- Modify `src/watcher/src/Driver/ScanFileDriver.php`. +- Modify `src/watcher/src/Driver/FswatchDriver.php`. +- Modify `src/watcher/src/ServerRestartStrategy.php`. +- Modify `src/watcher/config/watcher.php`. + +### Tests and fixtures + +- Modify `tests/Watcher/OptionTest.php`. +- Modify `tests/Watcher/WatchPathTest.php`. +- Replace obsolete `-mmin` coverage and migrate useful reference-lifecycle cases into `tests/Watcher/Driver/FindDriverTest.php`. +- Delete `tests/Watcher/Driver/FindNewerDriverTest.php`. +- Update `tests/Watcher/Fixtures/FindDriverStub.php` to match the consolidated protected seam. +- Delete `tests/Watcher/Fixtures/FindNewerDriverStub.php`. +- Modify `tests/Watcher/Driver/ScanFileDriverTest.php`. +- Modify `tests/Watcher/Driver/FswatchDriverTest.php`. +- Delete `tests/Watcher/Fixtures/FswatchDriverStub.php`; it replaces the real driver's select loop with an unrelated polling lifecycle and gates that fake behavior on the external executable. +- Modify `.github/docker/ci/Dockerfile` to install `fswatch` so the live Linux test runs instead of skipping. +- Modify `tests/Watcher/ServerRestartStrategyTest.php`. +- Add a focused `tests/Watcher/Driver/AbstractDriverTest.php` only if the inherited scan/wait lifecycle cannot be covered clearly through the existing driver tests without duplicating setup. Do not add it solely to test a protected helper in isolation. +- Modify `tests/Watcher/WatchCommandTest.php` to pin that the repeatable `--path` option requires a value before command execution. +- `tests/Watcher/WatcherTest.php` and `tests/Watcher/PackageMetadataTest.php` should remain unchanged unless implementation exposes a real integration or metadata change; no new dependency is required. +- Re-run `tests/Horizon` after the `Option` contract change. `Horizon\Console\ListenCommand` is the other `Option::fromConfig()` caller: it already rejects an empty effective watch list and its shipped paths are relative, so no Horizon source change is required. + +### Documentation + +- Modify `src/docs/watcher.md`. +- Modify `src/watcher/README.md` only to remove the stale upstream-tracking line. +- Modify the Watcher section of `docs/todo.md` by removing it after all work passes. + +## Test matrix + +### Option and WatchPath + +- Every base/recursive example in the table above. +- Empty string, leading slash, repeated/trailing separators, leading and interior `.` segments, explicit `.` and `./` root forms, preserved `..` sibling paths, duplicates before and after normalization, missing watch list, empty watch list, config empty plus CLI nonempty, config nonempty plus CLI paths, a bare `--path`, missing plain file, and scan interval validation. +- Existing matching semantics for hidden files, braces, ranges, `?`, single star, double star, trailing slash, and exact files. + +### Abstract polling lifecycle + +- Immediate first scan and one-interval first change detection. +- Stop before start, stop while scan yields, stop while waiting, repeated stop, and thrown scan cleanup. +- No real sleeps in source; use the existing channel/coroutine test seams. + +### FindDriver + +- Constructor probes system `find` with `command -v` on Linux and Darwin and fails clearly when its exit status is nonzero. +- Command arguments preserve spaces, quotes, shell syntax, newline filenames, operand-only `-H`, direct `-maxdepth 1`, recursive omission, explicit files, overlapping identical roots, and recursive-wins grouping. +- A first complete inventory containing only untouched pre-existing files is silent. A file changed after reference creation during that first traversal is emitted once and the cutoff advances safely. +- Later create, modify, delete, rename, directory removal/reappearance, empty target, and newly created target each emit correctly, subject only to the documented at-least-once overlap for a creation between changed and inventory passes. +- A changed path deleted before inventory emits deletion only; a path created between passes emits addition and remains eligible on the next cutoff. +- Slow scans retain changes made after traversal passes a path; very small positive scan intervals need no special rounding and work normally. +- Changed failure publishes complete NUL records found so far, ignores an unterminated tail, does not rotate the cutoff, and may repeat the path on the next cycle. +- Inventory failure publishes changed paths, advances the cutoff when changed traversals completed, keeps the old inventory, and emits no deletion. +- Recovery after a failed first inventory suppresses the pre-existing-tree addition flood while still deleting a changed path retained during degradation and removed before recovery. +- Multiple group failures log one accurate warning with an exit code. +- Quiet successful scans rotate reference roles. +- Reference paths are unique between instances; partial creation failure cleans up; touch failure and scan exception clean up; normal stop and repeated stop clean up; stop during an active scan never unlinks a referenced file early, publishes after stop, or rotates the cutoff. +- Repeated create/change/delete cycles keep inventory bounded to known-live matched paths. +- A symlinked directory operand is followed, while symlinks encountered during descent are not; ScanFile and Find retain the same matched paths. + +### ScanFileDriver + +- First scan establishes a silent baseline; add/modify/delete/rename then emit correctly regardless of Finder iteration order. +- Same-size and restored/coarse-mtime content rewrites remain detected by xxh128 hashing. +- Hidden files/directories are included when matched; VCS directories retain Finder defaults. +- Shallow root/file globs do not traverse or hash nested `vendor`, `node_modules`, or `storage`; recursive globs do. +- Identical targets walk once, recursive wins, multiple matchers work, overlapping different roots remain correct, and each matched file is hashed once. +- Explicit files, missing files/directories, file already found through a directory, and hash failure behave correctly. +- An unreadable watched root does not crash the scan or hide other roots, and loss/recovery emits deletion/addition for that root. An unreadable child does not hide later readable siblings. Where local permissions can enforce it, loss/recovery emits deletion/addition for that subtree. +- Large synthetic snapshots confirm order-independent diffing without timing assertions. + +### FswatchDriver + +- Exact Linux shallow/recursive and Darwin command arrays, including the `command -v` probe, NUL output, event filters, Linux monitor selection, per-group recursive flag, parent-directory mapping for exact files, and no `-E`. +- Existing operands are passed canonically; missing operands and mappings retain literal absolute spelling. Parent-segment and symlinked operands reconstruct every configured spelling, events outside every prefix are ignored, and an isolated missing operand becomes active and publishes after it appears. Include a missing operand beneath an existing symlink so literal-prefix behavior cannot regress. +- Shared operands promote to recursive. Only shallow-against-recursive canonical containment removes distinct command operands; nested recursive and nested shallow operands remain in their respective command. The supported `.` plus `../packages/foo/*.php` shape remains separate. Every distinct missing shallow operand remains even when lexically nested beneath a recursive operand. +- An existing shallow symlink that resolves outside a recursive tree remains in the shallow group and publishes events from its target. A missing shallow child that later becomes an outside symlink remains in the shallow group, and its literal prefix maps and publishes the event. These cases pin the coupling between retaining missing operands and retaining their literal matcher prefixes. +- An existing shallow symlink whose canonical target is an ordinary directory inside the recursive tree is removed from the shallow command while its canonical matcher entry still maps the configured symlink spelling and publishes once. +- Dropping a genuinely contained shallow operand leaves its matcher entry active and publishes its events once. Nested recursive operands are both passed to the recursive child, and nested shallow operands are both passed to the shallow child so direct children at each level remain observable. +- `app/**/*.php` plus an absent shallow `app/Generated/*.js` proves that an event delivered by the recursive operand can be accepted by another configured matcher. Multiple mappings or matchers accepting one record publish it once. Two exact files in one parent share an operand, while different configured spellings for one real directory retain their distinct mappings. +- An exact file's atomic temp-file replacement is observed through its watched parent. Sibling events from that parent are filtered, and multiple exact files in one parent add only one command target. +- Complete and fragmented NUL records, multiple records per read, embedded-newline filenames, empty records, unterminated EOF tail, read failure, unexpected child exit, path matcher exceptions, explicit stop, channel closure, and repeated cleanup. +- One Darwin process and one or two Linux processes as grouping requires. `stream_select()` reads ready records from both without detached coroutines. Stopping while selected terminates the direct children, wakes the loop through EOF, and leaves closure to the owner; repeated stop and stop-before-watch leave no handles. +- Pin that `proc_open()` receives an argument list rather than a shell command. + +### ServerRestartStrategy + +- Missing daemonize, explicit false, and explicit true. +- Null PID prints/signals nothing. +- Signal true succeeds, false prints failure, and Throwable prints failure. +- Output failure does not block signalling. +- Output-yield PID replacement signals only the newly current PID. +- Existing start/restart coalescing, stop-before-publication, final stop, process failure, environment reload, and cleanup behavior remain green. + +## Performance and resource verification + +Use isolated temporary trees and record the command, hardware/process conditions, wall time, CPU time, peak PHP memory, child-process count, open descriptors, and disk reads where available. Compare current `0.4` against the implementation using the same tree under warmed page-cache conditions, which represent a watcher polling the same tree every two seconds. Do not label measurements cold without a verified cache-eviction method. + +1. `ScanFileDriver` on 10,000 and 100,000 files: + - direct-child glob, recursive glob, broad root with a low match ratio, and duplicate identical targets; + - idle and one-file-changed cycles; + - wall/CPU, peak RSS, bytes read, and hashes performed. +2. `FindDriver` on the same trees: + - direct and recursive target groups, idle/change/delete cycles, high and low match ratios; + - at most four child traversals, NUL parse transient memory, retained inventory size, and temp files/fds after stop. +3. `FswatchDriver` on Linux: + - all-shallow configuration omits recursive registration; + - the default exact-root plus recursive `app`/`config` configuration uses two direct children and avoids registering unrelated project directories; + - identical targets and existing canonically-contained cross-group targets are not registered twice, while distinct missing shallow targets remain protected; + - inspect `/proc//fdinfo` for inotify watches and compare with `fs.inotify.max_user_watches`; + - verify at most two child processes and stable PHP memory while idle and under event bursts. +4. Run several watcher processes together to observe aggregate CPU, RSS, disk I/O, child-process count, temp files, and inotify descriptors. There must be no worker-lifetime growth across repeated cycles. + +Do not add benchmark-only production counters or CI timing thresholds. Existing protected seams and OS observations are enough. + +## Verification order + +Follow the repository's one-file-at-a-time workflow: + +1. Update one test file, then immediately run it from the repository root with `./vendor/bin/phpunit --no-progress `. +2. Make the corresponding source change and rerun that exact test file before moving on. +3. After all watcher files are green, run `./vendor/bin/phpunit --no-progress tests/Watcher`. +4. Run `./vendor/bin/phpunit --no-progress tests/Horizon` after the `Option` change to verify its second production caller. +5. Run the performance/resource checks above and compare against the unchanged 0.4 baseline. +6. On macOS, manually run `FswatchDriver` with the final `-0 --format %p [ -r ] --event ...` command and verify created, updated, removed, renamed, newline-containing filenames, and an exact file that is atomically replaced then edited again. Fragmented records remain covered deterministically by unit tests; Linux CI cannot verify the changed FSEvents command. +7. Run `composer fix` once as the repository checkpoint; do not duplicate its full formatter, PHPStan, parallel suite, or Testbench runs immediately beforehand. +8. Inspect `git diff` and `git status`; confirm deleted driver references, stale documentation/todos, temporary references, benchmark artifacts, and unrelated changes are absent. + +## Deliberately rejected machinery + +- No legacy `FindNewerDriver` alias, mode flag, or compatibility shim. +- No exact glob parser; one safe recursive boolean is enough because Symfony still owns matching. +- No `find` tag protocol or external per-path process. +- No streaming `proc_open()` replacement for short-lived find commands. +- No retained mtime map, repeat-suppression hash, metadata prescreen, periodic rehash, or scan tuning cap. +- No per-depth-group reference pairs or retry loop for broken filesystem traversal. +- No ScanFile or Find target-containment optimizer between different configured roots. +- No second degraded-mode state machine for Symfony Finder's unreadable subtree behavior. +- No fswatch filter-regex depth emulation, reader coroutines, pipe-tagging protocol, wake pipe, or timeout polling. +- No new Filesystem abstraction, worker-static cache, coroutine context, or global cleanup registry. +- No change to `Watcher`'s one-second fallback cleanup wait. Normal signal shutdown remains inside the channel loop until the active driver scan returns and closes the channel, so the driver has already finished before that wait begins. The timeout only guards a driver that closed its channel without ending or cleanup after another failure; deriving it from the polling interval would not bound scan duration. + +Each rejected mechanism either weakens detection, adds state for an exceptional condition, or costs more resources and maintenance than the verified problem it would solve. + +## Completion criteria + +- Audit findings 105–111 and every Watcher todo are implemented or closed by the superseding consolidated design. +- Only `ScanFileDriver`, `FindDriver`, and `FswatchDriver` remain documented and shipped. +- Valid newline-containing filenames survive both command-backed drivers. +- A bare `--path` is rejected by Symfony Console instead of reaching strict path normalization. +- Polling starts with an immediate silent baseline and detects the first later change within one interval. +- Find detects additions, modifications, renames, and deletions without GNU find or whole-second PHP mtime deduplication, and its degraded behavior never invents deletions or loses a changed cutoff silently. +- ScanFile retains exact-content detection while removing sorted/materialized directory walks and surviving unreadable watched roots and child subtrees. +- Fswatch uses NUL framing, one Darwin child or at most two direct Linux children, the minimum required Linux recursive registration, and one flat reader loop. +- Every retained collection and resource is naturally bounded and released at lifecycle end. +- Documentation describes real costs and failure behavior without exposing stale drivers or internal implementation noise. +- Targeted tests, watcher suite, performance/resource checks, manual macOS smoke, and `composer fix` pass. diff --git a/docs/todo.md b/docs/todo.md index c49f8af1bf..9fabca8ca2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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. diff --git a/src/docs/watcher.md b/src/docs/watcher.md index bed15fdc2f..5ae1539ca2 100644 --- a/src/docs/watcher.md +++ b/src/docs/watcher.md @@ -10,6 +10,7 @@ - [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) @@ -17,14 +18,14 @@ ## 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. ## 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 @@ -91,7 +92,7 @@ return [ ### 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' => [ @@ -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. + ### Scan Interval @@ -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. ### Server Command @@ -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, ``` + +### 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. + ### Custom Drivers @@ -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: diff --git a/src/watcher/README.md b/src/watcher/README.md index 94a3461496..438591e6d6 100644 --- a/src/watcher/README.md +++ b/src/watcher/README.md @@ -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 diff --git a/src/watcher/config/watcher.php b/src/watcher/config/watcher.php index d6d619359b..a9a96d6f2b 100644 --- a/src/watcher/config/watcher.php +++ b/src/watcher/config/watcher.php @@ -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. | */ @@ -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. | */ @@ -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. | */ diff --git a/src/watcher/src/Console/WatchCommand.php b/src/watcher/src/Console/WatchCommand.php index c5edd15e58..4492682b76 100644 --- a/src/watcher/src/Console/WatchCommand.php +++ b/src/watcher/src/Console/WatchCommand.php @@ -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'); } diff --git a/src/watcher/src/Driver/AbstractDriver.php b/src/watcher/src/Driver/AbstractDriver.php index 9add375eb4..c1af1ce5fa 100644 --- a/src/watcher/src/Driver/AbstractDriver.php +++ b/src/watcher/src/Driver/AbstractDriver.php @@ -41,12 +41,24 @@ 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; } @@ -54,13 +66,17 @@ protected function watchAtInterval(float $seconds, callable $scan): void try { while (true) { + $scan(); + + if ($this->isStopping()) { + return; + } + $signal = $stopSignal->pop($seconds); if ($signal !== false || ! $stopSignal->isTimeout()) { return; } - - $scan(); } } finally { if (! $stopSignal->isClosing()) { @@ -91,6 +107,27 @@ static function (WatchPath $watchPath): string { ); } + /** + * Group watch paths by their resolved target. + * + * @param list $watchPaths + * @return array}> + */ + 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. * diff --git a/src/watcher/src/Driver/FindDriver.php b/src/watcher/src/Driver/FindDriver.php index fc3bc08a20..238c132643 100644 --- a/src/watcher/src/Driver/FindDriver.php +++ b/src/watcher/src/Driver/FindDriver.php @@ -8,14 +8,20 @@ use Hypervel\Engine\Channel; use Hypervel\Watcher\Option; use InvalidArgumentException; +use RuntimeException; +use Throwable; class FindDriver extends AbstractDriver { - protected bool $supportsFractionalMinutes; + /** @var list */ + protected array $referenceFiles = []; - protected int $startTime = 0; + protected int $activeReferenceIndex = 0; - protected array $fileModifyTimes = []; + /** @var array */ + protected array $inventory = []; + + protected bool $hasCompleteInventory = false; public function __construct( Option $option, @@ -23,142 +29,313 @@ public function __construct( ) { parent::__construct($option); - $bin = $this->getBin(); - $result = $this->exec('which ' . $bin); - - if (empty($result['output'])) { - throw new InvalidArgumentException( - $this->isDarwin() - ? 'gfind not exists. You can `brew install findutils` to install it.' - : 'find not exists.', - ); + if ($this->exec('command -v find')['code'] !== 0) { + throw new InvalidArgumentException('The FindDriver requires the `find` executable.'); } - - $result = $this->exec($bin . ' --version'); - $this->supportsFractionalMinutes = $result['code'] === 0 - && str_contains($result['output'], 'GNU'); } /** - * Watch for file changes using the `find` command. + * Watch for file changes using `find -newer`. */ public function watch(Channel $channel): void { - $this->startTime = time(); - $seconds = $this->option->getScanIntervalSeconds(); + if ($this->isStopping()) { + return; + } - $this->watchAtInterval($seconds, function () use ($channel): void { - [$this->fileModifyTimes, $changedFiles] = $this->scan($this->fileModifyTimes, $this->getScanIntervalMinutes()); + $this->ensureReferenceFiles(); - foreach ($changedFiles as $file) { - $channel->push($file); - } - }); + try { + $this->watchAtInterval($this->option->getScanIntervalSeconds(), function () use ($channel): void { + $this->updateReferenceFile($this->inactiveReferenceFile()); + + if ($this->isStopping()) { + return; + } + + [ + 'files' => $changedFiles, + 'changedComplete' => $changedComplete, + 'inventoryComplete' => $inventoryComplete, + 'failureCode' => $failureCode, + ] = $this->scan(); + + if ($this->isStopping()) { + return; + } + + if ($changedComplete) { + $this->rotateReferenceFiles(); + } + + if ($failureCode !== null) { + $this->logDegradedCycle($failureCode, $changedComplete, $inventoryComplete); + } + + foreach ($changedFiles as $file) { + if ($this->isStopping()) { + return; + } + + $channel->push($file); + } + }); + } finally { + $this->removeReferenceFiles(); + } } /** - * Get the scan interval as a `find -mmin` compatible minutes string. + * Scan watched targets for changed and currently live files. + * + * @return array{ + * files: list, + * changedComplete: bool, + * inventoryComplete: bool, + * failureCode: null|int + * } */ - protected function getScanIntervalMinutes(): string + protected function scan(): array { - $minutes = $this->option->getScanIntervalSeconds() / 60; - if ($this->supportsFractionalMinutes) { - return sprintf('-%.2f', $minutes); + $targetDefinitions = $this->groupWatchPathsByTarget($this->option->getWatchPaths()); + + $targetGroups = [[], []]; + foreach ($this->existingTargets(array_keys($targetDefinitions)) as $target) { + $targetGroups[(int) $targetDefinitions[$target]['recursive']][] = $target; } - return sprintf('-%d', ceil($minutes)); + $changedFiles = []; + $currentInventory = []; + $changedComplete = true; + $inventoryComplete = true; + $failureCode = null; + + foreach ([false, true] as $recursive) { + $targets = $targetGroups[(int) $recursive]; + + if ($targets === []) { + continue; + } + + [$foundChanges, $changedExitCode] = $this->find($targets, $recursive, changed: true); + $changedFiles += $foundChanges; + + if ($changedExitCode !== 0) { + $changedComplete = false; + $failureCode ??= $changedExitCode; + } + + [$foundInventory, $inventoryExitCode] = $this->find($targets, $recursive, changed: false); + $currentInventory += $foundInventory; + + if ($inventoryExitCode !== 0) { + $inventoryComplete = false; + $failureCode ??= $inventoryExitCode; + } + } + + return [ + 'files' => $this->reconcileInventory($changedFiles, $currentInventory, $inventoryComplete), + 'changedComplete' => $changedComplete, + 'inventoryComplete' => $inventoryComplete, + 'failureCode' => $failureCode, + ]; } /** - * Find changed files in the given targets using the `find` command. - * - * @return array{array, list, int} + * Report the guarantees affected by a degraded scan cycle. */ - protected function find(array $fileModifyTimes, array $targets, string $minutes): array - { - $changedFiles = []; - $dest = $this->shellArguments($targets); - $ret = $this->exec($this->getBin() . ' ' . $dest . ' -mmin ' . $minutes . ' -type f -print'); - if (strlen($ret['output'])) { - $stdout = trim($ret['output']); - - $lineArr = explode(PHP_EOL, $stdout); - foreach ($lineArr as $line) { - $pathName = $line; - $modifyTime = @filemtime($pathName); - if ($modifyTime === false || $modifyTime < $this->startTime) { - continue; - } + protected function logDegradedCycle( + int $failureCode, + bool $changedComplete, + bool $inventoryComplete, + ): void { + $effects = []; - if (isset($fileModifyTimes[$pathName]) && $fileModifyTimes[$pathName] === $modifyTime) { - continue; - } - $fileModifyTimes[$pathName] = $modifyTime; - $changedFiles[] = $pathName; - } + if (! $changedComplete) { + $effects[] = 'Detected changes may repeat until the filesystem error is fixed.'; } - return [$fileModifyTimes, $changedFiles, $ret['code']]; + if (! $inventoryComplete) { + $effects[] = 'Deletion detection is suspended until the filesystem error is fixed.'; + } + + $this->logger->warning( + "One or more find commands exited with code {$failureCode}. " . implode(' ', $effects), + ); } /** - * Get the `find` binary name for the current OS. + * Run one changed-file or inventory traversal. + * + * @param list $targets + * @return array{array, int} */ - protected function getBin(): string + protected function find(array $targets, bool $recursive, bool $changed): array { - return $this->isDarwin() ? 'gfind' : 'find'; + $command = 'find -H ' . $this->shellArguments($targets); + + if (! $recursive) { + $command .= ' -maxdepth 1'; + } + + if ($changed) { + $command .= ' -newer ' . $this->shellArguments([$this->activeReferenceFile()]); + } + + $result = $this->exec($command . ' -type f -print0'); + + return [$this->matchingFiles($result['output']), $result['code']]; } /** - * Scan watched directories and files for changes. + * Parse complete matching paths from NUL-delimited command output. + * + * @return array */ - protected function scan(array $fileModifyTimes, string $minutes): array + protected function matchingFiles(string $output): array { - $changedFiles = []; - $directoryPaths = $this->option->getDirectoryPaths(); - $failureCode = null; - - // Scan all directories in a single find call. - $dirs = $this->existingTargets($this->resolveTargets($directoryPaths)); + $files = []; + $offset = 0; + $basePathLength = strlen(base_path()) + 1; + $watchPaths = $this->option->getWatchPaths(); - if ($dirs !== []) { - $basePath = base_path(); - [$fileModifyTimes, $found, $directoryExitCode] = $this->find($fileModifyTimes, $dirs, $minutes); + // Filter as records are parsed so a broad target never creates a second all-candidate list. + while (($separator = strpos($output, "\0", $offset)) !== false) { + $file = substr($output, $offset, $separator - $offset); + $offset = $separator + 1; - if ($directoryExitCode !== 0) { - $failureCode = $directoryExitCode; + if ($file === '') { + continue; } - foreach ($found as $file) { - $relativePath = substr($file, strlen($basePath) + 1); - foreach ($directoryPaths as $watchPath) { - if ($watchPath->matches($relativePath)) { - $changedFiles[] = $file; - break; - } + // find preserves the operand spelling, unlike fswatch's canonicalized output. + $relativePath = substr($file, $basePathLength); + + foreach ($watchPaths as $watchPath) { + if ($watchPath->matches($relativePath)) { + $files[$file] = true; + break; } } } - // Check individual watched files. - $files = $this->existingTargets($this->resolveTargets($this->option->getFilePaths())); + return $files; + } + + /** + * Reconcile changed files with the latest complete or partial inventory. + * + * @param array $changedFiles + * @param array $currentInventory + * @return list + */ + protected function reconcileInventory( + array $changedFiles, + array $currentInventory, + bool $inventoryComplete, + ): array { + if (! $inventoryComplete) { + $this->inventory += $changedFiles; + + return array_keys($changedFiles); + } + + $additions = array_diff_key($currentInventory, $this->inventory); + + if (! $this->hasCompleteInventory) { + $additions = array_intersect_key($additions, $changedFiles); + } + + $deletions = array_diff_key($this->inventory, $currentInventory); + $modifications = array_diff_key( + array_intersect_key($changedFiles, $currentInventory), + $additions, + ); - if ($files !== []) { - [$fileModifyTimes, $changed, $fileExitCode] = $this->find($fileModifyTimes, $files, $minutes); + $this->inventory = $currentInventory; + $this->hasCompleteInventory = true; - if ($fileExitCode !== 0) { - $failureCode ??= $fileExitCode; + return array_keys($additions + $deletions + $modifications); + } + + /** + * Ensure this lifecycle owns two unique reference files. + */ + protected function ensureReferenceFiles(): void + { + try { + while (count($this->referenceFiles) < 2) { + $this->referenceFiles[] = $this->createReferenceFile(); } + } catch (Throwable $exception) { + $this->removeReferenceFiles(); - $changedFiles = array_merge($changedFiles, $changed); + throw $exception; } + } + + /** + * Create a unique reference file. + */ + protected function createReferenceFile(): string + { + $path = @tempnam(sys_get_temp_dir(), 'hypervel-watcher-find-'); + + if ($path === false) { + throw new RuntimeException('Unable to create a watcher reference file.'); + } + + return $path; + } - if ($failureCode !== null) { - $this->logger->warning( - "One or more find commands exited with code {$failureCode} while scanning watched paths.", - ); + /** + * Update a reference file to the current filesystem timestamp. + */ + protected function updateReferenceFile(string $path): void + { + if (! @touch($path)) { + throw new RuntimeException("Unable to update the watcher reference file [{$path}]."); } + } + + /** + * Return the reference that bounds the current changed traversal. + */ + protected function activeReferenceFile(): string + { + return $this->referenceFiles[$this->activeReferenceIndex]; + } + + /** + * Return the reference that will bound the next changed traversal. + */ + protected function inactiveReferenceFile(): string + { + return $this->referenceFiles[1 - $this->activeReferenceIndex]; + } + + /** + * Make the pre-recorded next cutoff authoritative. + */ + protected function rotateReferenceFiles(): void + { + $this->activeReferenceIndex = 1 - $this->activeReferenceIndex; + } - return [$fileModifyTimes, $changedFiles]; + /** + * Remove every reference file owned by this lifecycle. + */ + protected function removeReferenceFiles(): void + { + $files = $this->referenceFiles; + $this->referenceFiles = []; + $this->activeReferenceIndex = 0; + + foreach ($files as $file) { + if (is_file($file) && ! @unlink($file)) { + $this->logger->warning("Unable to remove the watcher reference file [{$file}]."); + } + } } } diff --git a/src/watcher/src/Driver/FindNewerDriver.php b/src/watcher/src/Driver/FindNewerDriver.php deleted file mode 100644 index 5c15453084..0000000000 --- a/src/watcher/src/Driver/FindNewerDriver.php +++ /dev/null @@ -1,260 +0,0 @@ - */ - protected array $referenceFiles = []; - - protected bool $scanning = false; - - protected int $count = 0; - - public function __construct( - Option $option, - protected StdoutLoggerInterface $logger, - ) { - parent::__construct($option); - - $ret = $this->exec('which find'); - if (empty($ret['output'])) { - throw new InvalidArgumentException('find not exists.'); - } - $this->ensureReferenceFiles(); - } - - /** - * Watch for file changes using `find -newer`. - */ - public function watch(Channel $channel): void - { - if ($this->scanning) { - throw new RuntimeException('Cannot restart the find-newer watcher while its previous scan is still stopping.'); - } - - if ($this->stopping) { - return; - } - - $this->ensureReferenceFiles(); - - $seconds = $this->option->getScanIntervalSeconds(); - $this->watchAtInterval($seconds, function () use ($channel): void { - if ($this->scanning || $this->stopping) { - return; - } - $this->scanning = true; - try { - // Record the next cutoff before scanning so changes made after - // find passes their path remain eligible on the next tick. - $this->updateReferenceFile($this->getToModifyFile()); - - if ($this->stopping) { - return; - } - - [$changedFiles, $failureCode] = $this->scan(); - - if ($this->stopping) { - return; - } - - if ($failureCode === null) { - // Every successful scan swaps reference roles, including a - // quiet scan, so the pre-recorded cutoff becomes authoritative. - ++$this->count; - } else { - $this->logger->warning( - "One or more find commands exited with code {$failureCode} while scanning watched paths.", - ); - } - - foreach ($changedFiles as $file) { - $channel->push($file); - } - } finally { - $this->scanning = false; - - if ($this->stopping) { - $this->removeReferenceFiles(); - } - } - }); - } - - /** - * Stop watching and remove this driver's reference files. - */ - public function stop(): void - { - parent::stop(); - - if (! $this->scanning) { - $this->removeReferenceFiles(); - } - } - - /** - * Find files newer than the reference file in the given targets. - * - * @return array{list, int} - */ - protected function find(array $targets): array - { - $changedFiles = []; - $referenceFile = $this->shellArguments([$this->getToScanFile()]); - $ret = $this->exec(sprintf( - 'find %s -newer %s -type f -print', - $this->shellArguments($targets), - $referenceFile, - )); - - if (strlen($ret['output'])) { - $stdout = $ret['output']; - $lineArr = explode(PHP_EOL, $stdout); - foreach ($lineArr as $pathName) { - if (empty($pathName)) { - continue; - } - - $changedFiles[] = $pathName; - } - } - - return [$changedFiles, $ret['code']]; - } - - /** - * Scan watched directories and files for changes. - * - * The coroutine-aware find command may yield while stop state changes. - * - * @return array{list, null|int} - * - * @phpstan-impure - */ - protected function scan(): array - { - $changedFiles = []; - $basePath = base_path(); - $directoryPaths = $this->option->getDirectoryPaths(); - $failureCode = null; - - // Scan all directories in a single find call. - $dirs = $this->existingTargets($this->resolveTargets($directoryPaths)); - - if ($dirs !== []) { - [$found, $directoryExitCode] = $this->find($dirs); - - if ($directoryExitCode !== 0) { - $failureCode = $directoryExitCode; - } - - foreach ($found as $file) { - $relativePath = substr($file, strlen($basePath) + 1); - foreach ($directoryPaths as $watchPath) { - if ($watchPath->matches($relativePath)) { - $changedFiles[] = $file; - break; - } - } - } - } - - // Check individual watched files. - $files = $this->existingTargets($this->resolveTargets($this->option->getFilePaths())); - - if ($files !== []) { - [$changed, $fileExitCode] = $this->find($files); - - if ($fileExitCode !== 0) { - $failureCode ??= $fileExitCode; - } - - $changedFiles = array_merge($changedFiles, $changed); - } - - return [$changedFiles, $failureCode]; - } - - /** - * Get the path to the reference file to be modified. - */ - protected function getToModifyFile(): string - { - return $this->referenceFiles[$this->count % 2]; - } - - /** - * Get the path to the reference file used for scanning. - */ - protected function getToScanFile(): string - { - return $this->referenceFiles[($this->count + 1) % 2]; - } - - /** - * Ensure this driver owns two reference files for change comparisons. - */ - protected function ensureReferenceFiles(): void - { - try { - while (count($this->referenceFiles) < 2) { - $this->referenceFiles[] = $this->createReferenceFile(); - } - } catch (Throwable $exception) { - $this->removeReferenceFiles(); - - throw $exception; - } - } - - /** - * Create a unique reference file owned by this driver. - */ - protected function createReferenceFile(): string - { - $path = @tempnam(sys_get_temp_dir(), 'hypervel-watcher-find-'); - - if ($path === false) { - throw new RuntimeException('Unable to create a watcher reference file.'); - } - - return $path; - } - - /** - * Create or update a reference file used by `find -newer`. - */ - protected function updateReferenceFile(string $path): void - { - if (! @touch($path)) { - throw new RuntimeException("Unable to update the watcher reference file [{$path}]."); - } - } - - /** - * Remove every reference file currently owned by this driver. - */ - protected function removeReferenceFiles(): void - { - $files = $this->referenceFiles; - $this->referenceFiles = []; - - foreach ($files as $file) { - if ((is_file($file) || is_link($file)) && ! @unlink($file)) { - error_log("Unable to remove the watcher reference file [{$file}]."); - } - } - } -} diff --git a/src/watcher/src/Driver/FswatchDriver.php b/src/watcher/src/Driver/FswatchDriver.php index 3f36187706..37666f5fcb 100644 --- a/src/watcher/src/Driver/FswatchDriver.php +++ b/src/watcher/src/Driver/FswatchDriver.php @@ -7,22 +7,25 @@ use Hypervel\Engine\Channel; use Hypervel\Watcher\Option; use Hypervel\Watcher\WatchPath; +use Hypervel\Watcher\WatchPathType; use InvalidArgumentException; use RuntimeException; class FswatchDriver extends AbstractDriver { - protected mixed $process = null; + /** @var array */ + protected array $processes = []; - /** @var array */ + /** @var array */ protected array $pipes = []; - public function __construct(protected Option $option) + public function __construct(Option $option) { parent::__construct($option); - $result = $this->exec('which fswatch'); - if (empty($result['output'])) { - throw new InvalidArgumentException('fswatch not exists. You can `brew install fswatch` to install it.'); + + $result = $this->exec('command -v fswatch'); + if ($result['code'] !== 0) { + throw new InvalidArgumentException('The FswatchDriver requires the `fswatch` executable.'); } } @@ -31,132 +34,343 @@ public function __construct(protected Option $option) */ public function watch(Channel $channel): void { - if ($this->stopping) { + if ($this->isStopping()) { return; } - $this->openProcess(); + $watchPaths = $this->option->getWatchPaths(); + $watchTargets = $this->resolveWatchTargets($watchPaths); try { - $pipe = $this->pipes[1] ?? null; + $this->openProcesses($watchTargets['groups']); - if (! is_resource($pipe)) { + if ($this->pipes === []) { throw new RuntimeException('The fswatch process did not provide an output pipe.'); } - $basePath = base_path(); - $watchPaths = $this->option->getWatchPaths(); - $buffer = ''; + $buffers = array_fill_keys(array_keys($this->pipes), ''); while (true) { if ($this->shouldStopWatching($channel)) { return; } - $result = fread($pipe, 8192); + $readyPipes = $this->pipes; + $writePipes = null; + $exceptPipes = null; + $ready = stream_select($readyPipes, $writePipes, $exceptPipes, null); if ($this->shouldStopWatching($channel)) { return; } - if ($result === false) { + if ($ready === false) { throw new RuntimeException('Unable to read output from the fswatch process.'); } - if ($result === '') { - if (feof($pipe)) { - $this->processOutput($buffer, '', $channel, $basePath, $watchPaths, final: true); + foreach ($readyPipes as $group => $pipe) { + if ($this->shouldStopWatching($channel)) { + return; + } + + $result = fread($pipe, 8192); - throw new RuntimeException('The fswatch process exited unexpectedly.'); + if ($result === false) { + throw new RuntimeException('Unable to read output from the fswatch process.'); } - throw new RuntimeException('Unable to read output from the fswatch process.'); - } + if ($result === '') { + if (feof($pipe)) { + throw new RuntimeException('The fswatch process exited unexpectedly.'); + } + + throw new RuntimeException('Unable to read output from the fswatch process.'); + } - $this->processOutput($buffer, $result, $channel, $basePath, $watchPaths); + $this->processOutput( + $buffers[$group], + $result, + $channel, + $watchPaths, + $watchTargets['entries'], + ); + } } } finally { $this->stop(); + $this->closeProcesses(); } } /** - * Process complete newline-delimited paths while retaining a partial tail. + * Process complete NUL-delimited paths while retaining a partial tail. * * @param list $watchPaths + * @param list $watchTargets */ protected function processOutput( string &$buffer, string $chunk, Channel $channel, - string $basePath, array $watchPaths, - bool $final = false, + array $watchTargets, ): void { - $lines = explode("\n", $buffer . $chunk); - $buffer = array_pop($lines); + $buffer .= $chunk; + $offset = 0; - if ($final && $buffer !== '') { - $lines[] = $buffer; - $buffer = ''; - } + while (($separator = strpos($buffer, "\0", $offset)) !== false) { + $file = substr($buffer, $offset, $separator - $offset); + $offset = $separator + 1; - foreach ($lines as $file) { if ($file === '') { continue; } - $relativePath = substr($file, strlen($basePath) + 1); + $matched = false; + foreach ($watchTargets as $watchTarget) { + if (! str_starts_with($file, $watchTarget['prefix'])) { + continue; + } + + $remainder = substr($file, strlen($watchTarget['prefix'])); + $relativePath = $watchTarget['base'] === '.' + ? $remainder + : $watchTarget['base'] . '/' . $remainder; - foreach ($watchPaths as $watchPath) { - if ($watchPath->matches($relativePath)) { - $channel->push($file); - break; + // A recursive operand can observe a path whose configured base did not exist at startup. + foreach ($watchPaths as $watchPath) { + if ($watchPath->matches($relativePath)) { + $matched = true; + break 2; + } } } + + if ($matched) { + $channel->push($file); + } + } + + if ($offset > 0) { + $buffer = substr($buffer, $offset); } } /** - * Stop the fswatch process. + * Stop the active fswatch processes. */ public function stop(): void { parent::stop(); - $process = $this->process; - $this->process = null; + foreach ($this->processes as $process) { + if (is_resource($process) && proc_get_status($process)['running']) { + proc_terminate($process, SIGKILL); + } + } + } - if (is_resource($process) && proc_get_status($process)['running']) { - proc_terminate($process, SIGKILL); + /** + * Open every configured fswatch process group. + * + * @param array}> $groups + */ + protected function openProcesses(array $groups): void + { + foreach ($groups as $group => $settings) { + $this->openProcess($group, $settings['operands'], $settings['recursive']); } + } + + /** + * Open one fswatch subprocess and retain its output pipe. + * + * @param list $operands + */ + protected function openProcess(string $group, array $operands, bool $recursive): void + { + // The argument-list form bypasses a shell whose descendants could keep stdout open after termination. + $process = proc_open($this->getCommand($operands, $recursive), [STDIN, ['pipe', 'w']], $pipes); + + if (! is_resource($process)) { + throw new RuntimeException('fswatch failed.'); + } + + $pipe = $pipes[1] ?? null; + + if (! is_resource($pipe)) { + if (proc_get_status($process)['running']) { + proc_terminate($process, SIGKILL); + } + + proc_close($process); + + throw new RuntimeException('The fswatch process did not provide an output pipe.'); + } + + $this->processes[$group] = $process; + $this->pipes[$group] = $pipe; + } + + /** + * Close every process resource owned by the active watch lifecycle. + */ + protected function closeProcesses(): void + { + foreach (array_keys($this->pipes) as $group) { + $pipe = $this->pipes[$group]; + unset($this->pipes[$group]); - foreach ($this->pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } } - $this->pipes = []; + foreach (array_keys($this->processes) as $group) { + $process = $this->processes[$group]; + unset($this->processes[$group]); + + if (! is_resource($process)) { + continue; + } + + if (proc_get_status($process)['running']) { + proc_terminate($process, SIGKILL); + } - if (is_resource($process)) { proc_close($process); } } /** - * Open the fswatch subprocess and retain its pipes. + * Resolve command operands and canonical matcher mappings. + * + * @param list $watchPaths + * @return array{ + * groups: array}>, + * entries: list + * } */ - protected function openProcess(): void + protected function resolveWatchTargets(array $watchPaths): array { - $process = proc_open($this->getCommand(), [['pipe', 'r'], ['pipe', 'w']], $pipes); + /** @var array $targets */ + $targets = []; + $entries = []; + + foreach ($watchPaths as $watchPath) { + $base = $watchPath->type === WatchPathType::File + ? dirname($watchPath->path) + : rtrim($watchPath->path, '/'); + $base = $base === '' ? '.' : $base; + $literalPath = $base === '.' ? base_path() : base_path($base); + $canonicalPath = realpath($literalPath); + // A missing root keeps one literal operand/prefix because fswatch may activate it + // as a symlink that a recursive parent process does not follow. + $operand = $canonicalPath === false ? $literalPath : $canonicalPath; + + if (! isset($targets[$operand])) { + $targets[$operand] = [ + 'operand' => $operand, + 'recursive' => false, + 'canonical' => $canonicalPath === false ? null : $canonicalPath, + ]; + } - if (! is_resource($process)) { - throw new RuntimeException('fswatch failed.'); + $targets[$operand]['recursive'] = $targets[$operand]['recursive'] || $watchPath->recursive; + + $prefix = rtrim($operand, '/') . '/'; + $entryKey = $prefix . "\0" . $base; + $entries[$entryKey] ??= ['prefix' => $prefix, 'base' => $base]; } - $this->process = $process; - $this->pipes = $pipes; + return [ + 'groups' => $this->groupWatchTargets($targets), + 'entries' => array_values($entries), + ]; + } + + /** + * Group watch targets by the recursion depth their process requires. + * + * @param array $targets + * @return array}> + */ + protected function groupWatchTargets(array $targets): array + { + if ($this->isDarwin()) { + // FSEvents observes every operand recursively, so Darwin needs one unpruned process group. + return ['all' => [ + 'recursive' => array_any( + $targets, + static fn (array $target): bool => $target['recursive'], + ), + 'operands' => array_column($targets, 'operand'), + ]]; + } + + $recursiveTargets = array_filter( + $targets, + static fn (array $target): bool => $target['recursive'], + ); + $shallowTargets = array_filter( + $targets, + static fn (array $target): bool => ! $target['recursive'], + ); + $shallowOperands = []; + + foreach ($shallowTargets as $shallowTarget) { + $contained = false; + + foreach ($recursiveTargets as $recursiveTarget) { + if ( + $shallowTarget['canonical'] !== null + && $recursiveTarget['canonical'] !== null + && $this->isContainedBy($shallowTarget['canonical'], $recursiveTarget['canonical']) + ) { + $contained = true; + break; + } + } + + if (! $contained) { + $shallowOperands[] = $shallowTarget['operand']; + } + } + + $groups = []; + + if ($shallowOperands !== []) { + $groups['shallow'] = ['recursive' => false, 'operands' => $shallowOperands]; + } + + if ($recursiveTargets !== []) { + $groups['recursive'] = [ + 'recursive' => true, + 'operands' => array_column($recursiveTargets, 'operand'), + ]; + } + + return $groups; + } + + /** + * Determine whether a path is equal to or nested beneath another path. + * + * Both paths must be canonical because a literal path can escape its + * lexical parent through a symlink. + */ + protected function isContainedBy(string $path, string $parent): bool + { + $path = rtrim($path, '/') ?: '/'; + $parent = rtrim($parent, '/') ?: '/'; + + if ($path === $parent) { + return true; + } + + $prefix = $parent === '/' ? '/' : $parent . '/'; + + return str_starts_with($path, $prefix); } /** @@ -168,30 +382,31 @@ protected function openProcess(): void */ protected function shouldStopWatching(Channel $channel): bool { - return $this->stopping || $channel->isClosing() || $this->process === null; + return $this->isStopping() || $channel->isClosing(); } /** * Build the fswatch command arguments. * + * @param list $operands * @return list */ - protected function getCommand(): array + protected function getCommand(array $operands, bool $recursive): array { - $paths = $this->resolveTargets($this->option->getWatchPaths()); + $command = ['fswatch']; - if ($this->isDarwin()) { - return ['fswatch', ...$paths]; + if (! $this->isDarwin()) { + array_push($command, '-m', 'inotify_monitor'); } - return [ - 'fswatch', - '-m', - 'inotify_monitor', - '-E', - '--format', - '%p', - '-r', + array_push($command, '-0', '--format', '%p'); + + if ($recursive) { + $command[] = '-r'; + } + + array_push( + $command, '--event', 'Created', '--event', @@ -200,7 +415,8 @@ protected function getCommand(): array 'Removed', '--event', 'Renamed', - ...$paths, - ]; + ); + + return [...$command, ...$operands]; } } diff --git a/src/watcher/src/Driver/ScanFileDriver.php b/src/watcher/src/Driver/ScanFileDriver.php index be540446ca..eedf15bae2 100644 --- a/src/watcher/src/Driver/ScanFileDriver.php +++ b/src/watcher/src/Driver/ScanFileDriver.php @@ -9,21 +9,20 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Watcher\Option; use Symfony\Component\Finder\Exception\DirectoryNotFoundException; -use Symfony\Component\Finder\SplFileInfo; +use Symfony\Component\Finder\Finder; +use UnexpectedValueException; class ScanFileDriver extends AbstractDriver { protected Filesystem $filesystem; - /** - * @var null|array - */ + /** @var null|array */ protected ?array $lastFileHashes = null; public function __construct( - protected Option $option, - private StdoutLoggerInterface $logger, - ?Filesystem $filesystem = null + Option $option, + protected StdoutLoggerInterface $logger, + ?Filesystem $filesystem = null, ) { parent::__construct($option); @@ -35,8 +34,7 @@ public function __construct( */ public function watch(Channel $channel): void { - $seconds = $this->option->getScanIntervalSeconds(); - $this->watchAtInterval($seconds, function () use ($channel): void { + $this->watchAtInterval($this->option->getScanIntervalSeconds(), function () use ($channel): void { $this->processFileHashes($channel, $this->getWatchFileHashes()); }); } @@ -48,38 +46,38 @@ public function watch(Channel $channel): void */ protected function processFileHashes(Channel $channel, array $currentFileHashes): void { - if ($this->lastFileHashes !== null && $this->lastFileHashes !== $currentFileHashes) { - // Added files (in current but not in last). + if ($this->lastFileHashes !== null) { $addedFiles = array_diff_key($currentFileHashes, $this->lastFileHashes); - foreach (array_keys($addedFiles) as $pathName) { - $channel->push($pathName); - } - - // Deleted files (in last but not in current). $deletedFiles = array_diff_key($this->lastFileHashes, $currentFileHashes); - foreach (array_keys($deletedFiles) as $pathName) { - $channel->push($pathName); - } - - // Modified files (same path, different hash). $modifiedFiles = []; + foreach ($currentFileHashes as $pathName => $fileHash) { if (isset($this->lastFileHashes[$pathName]) && $this->lastFileHashes[$pathName] !== $fileHash) { $modifiedFiles[] = $pathName; } } - $this->logger->debug(sprintf( - '%s Watching: Total:%d, Change:%d, Add:%d, Delete:%d.', - self::class, - count($currentFileHashes), - count($modifiedFiles), - count($addedFiles), - count($deletedFiles), - )); - - foreach ($modifiedFiles as $pathName) { - $channel->push($pathName); + if ($addedFiles !== [] || $deletedFiles !== [] || $modifiedFiles !== []) { + $this->logger->debug(sprintf( + '%s Watching: Total:%d, Change:%d, Add:%d, Delete:%d.', + self::class, + count($currentFileHashes), + count($modifiedFiles), + count($addedFiles), + count($deletedFiles), + )); + + foreach (array_keys($addedFiles) as $pathName) { + $channel->push($pathName); + } + + foreach (array_keys($deletedFiles) as $pathName) { + $channel->push($pathName); + } + + foreach ($modifiedFiles as $pathName) { + $channel->push($pathName); + } } } @@ -94,41 +92,61 @@ protected function processFileHashes(Channel $channel, array $currentFileHashes) protected function getWatchFileHashes(): array { $fileHashes = []; - $basePath = null; - - // Scan watched directories. + $basePathLength = strlen(base_path()) + 1; $directoryPaths = $this->option->getDirectoryPaths(); - $directoryTargets = $this->resolveTargets($directoryPaths); + $targetGroups = $this->groupWatchPathsByTarget($directoryPaths); - foreach ($directoryPaths as $index => $watchPath) { + foreach ($targetGroups as $target => $group) { try { - $allFiles = $this->filesystem->allFiles($directoryTargets[$index]); - } catch (DirectoryNotFoundException) { - continue; - } - - /** @var SplFileInfo $obj */ - foreach ($allFiles as $obj) { - $pathName = $obj->getPathName(); - $basePath ??= base_path(); - $relativePath = substr($pathName, strlen($basePath) + 1); - if (! $watchPath->matches($relativePath)) { - continue; + $finder = Finder::create() + ->files() + ->ignoreDotFiles(false) + ->ignoreUnreadableDirs() + ->in($target); + + if (! $group['recursive']) { + $finder->depth(0); } - $fileHash = $this->hashFile($pathName); - if ($fileHash !== null) { - $fileHashes[$pathName] = $fileHash; + + foreach ($finder as $file) { + $pathName = $file->getPathname(); + + if (isset($fileHashes[$pathName])) { + continue; + } + + // Finder preserves the target spelling, unlike fswatch's canonicalized output. + $relativePath = substr($pathName, $basePathLength); + + foreach ($group['watchPaths'] as $watchPath) { + if (! $watchPath->matches($relativePath)) { + continue; + } + + $fileHash = $this->hashFile($pathName); + + if ($fileHash !== null) { + $fileHashes[$pathName] = $fileHash; + } + + break; + } } + } catch (DirectoryNotFoundException|UnexpectedValueException) { + // RecursiveDirectoryIterator throws while opening an unreadable root before Finder can skip unreadable children. + continue; } } - // Check individual watched files. foreach ($this->resolveTargets($this->option->getFilePaths()) as $pathName) { - if (file_exists($pathName)) { - $fileHash = $this->hashFile($pathName); - if ($fileHash !== null) { - $fileHashes[$pathName] = $fileHash; - } + if (isset($fileHashes[$pathName]) || ! is_file($pathName)) { + continue; + } + + $fileHash = $this->hashFile($pathName); + + if ($fileHash !== null) { + $fileHashes[$pathName] = $fileHash; } } diff --git a/src/watcher/src/Option.php b/src/watcher/src/Option.php index 3378142b9d..c4c21032be 100644 --- a/src/watcher/src/Option.php +++ b/src/watcher/src/Option.php @@ -34,11 +34,20 @@ public function __construct( */ public static function fromConfig(array $config, string $basePath, array $extraPaths = []): static { - $rawPaths = array_unique(array_merge($config['watch'] ?? [], $extraPaths)); + $rawPaths = array_merge($config['watch'] ?? [], $extraPaths); + + if ($rawPaths === []) { + throw new InvalidArgumentException('The watcher requires at least one watch path.'); + } + + $normalizedPaths = []; + foreach ($rawPaths as $rawPath) { + $normalizedPaths[self::normalizeEntry($rawPath)] = true; + } $watchPaths = array_map( fn (string $entry) => self::parseEntry($entry, $basePath), - array_values($rawPaths), + array_keys($normalizedPaths), ); return new static( @@ -48,6 +57,29 @@ public static function fromConfig(array $config, string $basePath, array $extraP ); } + /** + * Normalize a watch config entry. + */ + protected static function normalizeEntry(string $entry): string + { + if ($entry === '') { + throw new InvalidArgumentException('Watcher paths must not be empty.'); + } + + if (str_starts_with($entry, '/')) { + throw new InvalidArgumentException('Watcher paths must be relative to the application base path.'); + } + + $segments = []; + foreach (explode('/', $entry) as $segment) { + if ($segment !== '' && $segment !== '.') { + $segments[] = $segment; + } + } + + return $segments === [] ? '.' : implode('/', $segments); + } + /** * Parse a single watch config entry into a WatchPath. */ @@ -70,11 +102,13 @@ protected static function parseEntry(string $entry, string $basePath): WatchPath protected static function parseGlob(string $glob): WatchPath { preg_match('/[*?{\[]/', $glob, $matches, PREG_OFFSET_CAPTURE); - $wildcardPos = $matches[0][1]; - $baseDir = rtrim(substr($glob, 0, $wildcardPos), '/'); + $wildcardPosition = $matches[0][1]; + $prefix = substr($glob, 0, $wildcardPosition); + $slashPosition = strrpos($prefix, '/'); + $baseDirectory = $slashPosition === false ? '.' : substr($prefix, 0, $slashPosition); return new WatchPath( - path: $baseDir ?: '.', + path: $baseDirectory, type: WatchPathType::Directory, pattern: $glob, ); @@ -99,7 +133,7 @@ public function getDirectoryPaths(): array { return array_values(array_filter( $this->watchPaths, - fn (WatchPath $p) => $p->type === WatchPathType::Directory, + fn (WatchPath $watchPath) => $watchPath->type === WatchPathType::Directory, )); } @@ -112,7 +146,7 @@ public function getFilePaths(): array { return array_values(array_filter( $this->watchPaths, - fn (WatchPath $p) => $p->type === WatchPathType::File, + fn (WatchPath $watchPath) => $watchPath->type === WatchPathType::File, )); } diff --git a/src/watcher/src/ServerRestartStrategy.php b/src/watcher/src/ServerRestartStrategy.php index 6e4600f529..7168476890 100644 --- a/src/watcher/src/ServerRestartStrategy.php +++ b/src/watcher/src/ServerRestartStrategy.php @@ -35,7 +35,7 @@ public function __construct( ) { $config = $container->make('config'); - if ($config->boolean('server.settings.daemonize')) { + if ($config->boolean('server.settings.daemonize', false)) { throw new InvalidArgumentException('Please set `server.settings.daemonize` to false'); } @@ -101,11 +101,27 @@ public function stop(): void $this->terminateServer(); } + /** + * Return the currently published server process ID. + * + * The value may change while hooked output yields to another coroutine. + * + * @phpstan-impure + */ + protected function currentProcessId(): ?int + { + return $this->processId; + } + /** * Terminate the currently published server process. */ protected function terminateServer(): void { + if ($this->currentProcessId() === null) { + return; + } + try { $this->output->writeln('Stop server...'); } catch (Throwable) { @@ -113,19 +129,29 @@ protected function terminateServer(): void // No yielding work may occur after this read: the PID belongs to the // exact unreaped child retained by the owner coroutine. - $pid = $this->processId; + $pid = $this->currentProcessId(); if ($pid === null) { return; } try { - $this->signalProcess($pid, SIGTERM); - } catch (Throwable) { - try { - $this->output->writeln('Stop server failed.'); - } catch (Throwable) { + if ($this->signalProcess($pid, SIGTERM) === false) { + $this->reportStopFailure(); } + } catch (Throwable) { + $this->reportStopFailure(); + } + } + + /** + * Report a failed server termination. + */ + protected function reportStopFailure(): void + { + try { + $this->output->writeln('Stop server failed.'); + } catch (Throwable) { } } diff --git a/src/watcher/src/WatchPath.php b/src/watcher/src/WatchPath.php index c9bff8d132..7c25d1133f 100644 --- a/src/watcher/src/WatchPath.php +++ b/src/watcher/src/WatchPath.php @@ -10,6 +10,9 @@ { private ?string $regex; + /** Whether this path requires recursive filesystem traversal. */ + public bool $recursive; + /** * @param string $path Relative base path (e.g., 'app', 'config', '.env') * @param WatchPathType $type Whether this entry represents a directory or a file @@ -23,6 +26,16 @@ public function __construct( $this->regex = $type === WatchPathType::Directory && $pattern !== null ? Glob::toRegex($pattern, strictLeadingDot: false) : null; + + if ($type === WatchPathType::File) { + $this->recursive = false; + } elseif ($pattern === null) { + $this->recursive = true; + } else { + $suffix = $path === '.' ? $pattern : substr($pattern, strlen($path) + 1); + // Symfony's `app/**` glob matches deep descendants despite having no slash in its suffix. + $this->recursive = str_contains($suffix, '/') || str_contains($suffix, '**'); + } } /** diff --git a/tests/Watcher/Driver/AbstractDriverTest.php b/tests/Watcher/Driver/AbstractDriverTest.php new file mode 100644 index 0000000000..01beaac3b3 --- /dev/null +++ b/tests/Watcher/Driver/AbstractDriverTest.php @@ -0,0 +1,178 @@ +push(++$scanCount); + }); + $output = new Channel(1); + $finished = new WaitGroup(1); + + Coroutine::create(function () use ($driver, $finished, $output): void { + try { + $driver->watch($output); + } finally { + $finished->done(); + } + }); + + try { + $this->assertSame(1, $scans->pop(0.05)); + $this->assertFalse($scans->pop(0.05)); + $this->assertSame(2, $scans->pop(0.3)); + } finally { + $driver->stop(); + $this->assertTrue($finished->wait(0.1)); + $scans->close(); + $output->close(); + } + } + + public function testStopBeforeWatchPreventsTheInitialScan(): void + { + $scanCount = 0; + $driver = new PollingDriverStub(1.0, function () use (&$scanCount): void { + ++$scanCount; + }); + $output = new Channel(1); + + try { + $driver->stop(); + $driver->stop(); + $driver->watch($output); + + $this->assertSame(0, $scanCount); + $this->assertFalse($driver->hasActiveStopSignal()); + } finally { + $output->close(); + } + } + + public function testStopDuringAYieldingScanPreventsAnotherWaitOrScan(): void + { + $entered = new Channel(1); + $resume = new Channel(1); + $scanCount = 0; + $driver = new PollingDriverStub(60.0, function () use ($entered, $resume, &$scanCount): void { + ++$scanCount; + $entered->push(true); + $resume->pop(); + }); + $output = new Channel(1); + $finished = new WaitGroup(1); + + Coroutine::create(function () use ($driver, $finished, $output): void { + try { + $driver->watch($output); + } finally { + $finished->done(); + } + }); + + try { + $this->assertTrue($entered->pop(0.1)); + $driver->stop(); + $resume->push(true); + + $this->assertTrue($finished->wait(0.1)); + $this->assertSame(1, $scanCount); + $this->assertFalse($driver->hasActiveStopSignal()); + } finally { + $driver->stop(); + $entered->close(); + $resume->close(); + $output->close(); + } + } + + public function testStopWhileWaitingEndsTheLifecycleWithoutAnotherScan(): void + { + $scanned = new Channel(1); + $scanCount = 0; + $driver = new PollingDriverStub(60.0, function () use ($scanned, &$scanCount): void { + ++$scanCount; + $scanned->push(true); + }); + $output = new Channel(1); + $finished = new WaitGroup(1); + + Coroutine::create(function () use ($driver, $finished, $output): void { + try { + $driver->watch($output); + } finally { + $finished->done(); + } + }); + + try { + $this->assertTrue($scanned->pop(0.1)); + $driver->stop(); + + $this->assertTrue($finished->wait(0.1)); + $this->assertSame(1, $scanCount); + $this->assertFalse($driver->hasActiveStopSignal()); + } finally { + $driver->stop(); + $scanned->close(); + $output->close(); + } + } + + public function testScanExceptionCleansTheStopSignal(): void + { + $exception = new RuntimeException('scan failed'); + $driver = new PollingDriverStub(1.0, function () use ($exception): never { + throw $exception; + }); + $output = new Channel(1); + $caught = null; + + try { + $driver->watch($output); + } catch (RuntimeException $caughtException) { + $caught = $caughtException; + } finally { + $output->close(); + } + + $this->assertSame($exception, $caught); + $this->assertFalse($driver->hasActiveStopSignal()); + } +} + +class PollingDriverStub extends AbstractDriver +{ + public function __construct( + protected float $interval, + protected Closure $scan, + ) { + parent::__construct(new Option); + } + + public function watch(Channel $channel): void + { + $this->watchAtInterval($this->interval, $this->scan); + } + + public function hasActiveStopSignal(): bool + { + return $this->stopSignal !== null; + } +} diff --git a/tests/Watcher/Driver/FindDriverTest.php b/tests/Watcher/Driver/FindDriverTest.php index 3f9ead81cc..dacfcc14ef 100644 --- a/tests/Watcher/Driver/FindDriverTest.php +++ b/tests/Watcher/Driver/FindDriverTest.php @@ -9,8 +9,8 @@ use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; use Hypervel\Filesystem\Filesystem; +use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; -use Hypervel\Tests\TestCase; use Hypervel\Tests\Watcher\Fixtures\ContainerStub; use Hypervel\Tests\Watcher\Fixtures\FindDriverStub; use Hypervel\Watcher\Driver\FindDriver; @@ -19,6 +19,7 @@ use Hypervel\Watcher\WatchPathType; use InvalidArgumentException; use Mockery as m; +use RuntimeException; class FindDriverTest extends TestCase { @@ -29,9 +30,9 @@ protected function setUp(): void parent::setUp(); $this->tempDir = ParallelTesting::tempDir('watcher-find-driver'); - $files = new Filesystem; - $files->deleteDirectory($this->tempDir); - $files->ensureDirectoryExists($this->tempDir); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($this->tempDir); + $filesystem->ensureDirectoryExists($this->tempDir); } protected function tearDown(): void @@ -41,18 +42,14 @@ protected function tearDown(): void parent::tearDown(); } - public function testWatch(): void + public function testWatchPublishesEveryChangedFileAndCleansItsReferences(): void { $option = new Option( driver: FindDriver::class, - watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), - new WatchPath('.env', WatchPathType::File), - ], + watchPaths: [new WatchPath('.env', WatchPathType::File)], scanInterval: 1, ); - - $channel = new Channel(10); + $channel = new Channel(2); try { $driver = new FindDriverStub($option, ContainerStub::getLogger()); @@ -65,282 +62,928 @@ public function testWatch(): void } }); - $this->assertSame('.env', $channel->pop($option->getScanIntervalSeconds() + 0.1)); - } catch (InvalidArgumentException $e) { - if (str_contains($e->getMessage(), 'find not exists')) { + $this->assertSame('.env', $channel->pop(0.1)); + } catch (InvalidArgumentException $exception) { + if (str_contains($exception->getMessage(), 'requires the `find` executable')) { $this->markTestSkipped(); } - throw $e; + + throw $exception; } finally { if (isset($driver)) { $driver->stop(); } + if (isset($finished)) { $this->assertTrue($finished->wait(0.1)); + $this->assertSame([], $driver->referenceFilesForTest()); } + $channel->close(); } } - public function testFindEscapesEveryTargetPath(): void + public function testConstructorProbesThePortableSystemFindCommand(): void { - $option = new Option(driver: FindDriver::class, watchPaths: [], scanInterval: 1); - $driver = new class($option, ContainerStub::getLogger()) extends FindDriver { - public string $capturedCommand = ''; + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); - protected function exec(string $command): array - { - if (str_starts_with($command, 'which ')) { - return ['code' => 0, 'output' => '/usr/bin/find']; - } + $this->assertSame('command -v find', $driver->commands[0]); + } - if ($command === 'find --version') { - return ['code' => 0, 'output' => 'GNU findutils']; + public function testConstructorUsesTheProbeExitCode(): void + { + $exception = null; + + try { + new class($this->option(), ContainerStub::getLogger()) extends FindDriver { + protected function exec(string $command): array + { + return ['code' => 1, 'output' => '/unexpected/output']; } + }; + } catch (InvalidArgumentException $caughtException) { + $exception = $caughtException; + } - $this->capturedCommand = $command; + $this->assertSame( + 'The FindDriver requires the `find` executable.', + $exception?->getMessage(), + ); + } - return ['code' => 0, 'output' => '']; - } + public function testBuildsEscapedDirectAndRecursiveFindCommands(): void + { + $driver = new ScriptedFindDriver( + $this->option(), + ContainerStub::getLogger(), + [ + ['code' => 0, 'output' => ''], + ['code' => 0, 'output' => ''], + ], + ); + $targets = [ + '/tmp/path with spaces', + "/tmp/path'quoted", + "/tmp/path\nwith-newline", + '/tmp/$(ignored);touch nope', + ]; - public function findForTest(array $targets): void - { - $this->find([], $targets, '-0.10'); - } - }; - $targets = ['/tmp/path with spaces', "/tmp/path'quoted", '/tmp/$(ignored);touch nope']; + try { + $driver->ensureReferenceFilesForTest(); + $driver->findForTest($targets, recursive: false, changed: true); + $driver->findForTest($targets, recursive: true, changed: false); + + $arguments = implode(' ', array_map(escapeshellarg(...), $targets)); + $reference = escapeshellarg($driver->activeReferenceForTest()); + $this->assertSame( + "find -H {$arguments} -maxdepth 1 -newer {$reference} -type f -print0", + $driver->commands[1], + ); + $this->assertSame( + "find -H {$arguments} -type f -print0", + $driver->commands[2], + ); + } finally { + $driver->removeReferenceFilesForTest(); + } + } + + public function testParsesOnlyCompleteMatchingNulRecordsWithoutCorruptingNewlines(): void + { + $matching = base_path("app/file\nname.php"); + $unmatched = base_path('config/app.php'); + $unterminated = base_path('app/incomplete.php'); + $driver = new ScriptedFindDriver(new Option(watchPaths: [ + new WatchPath('app', WatchPathType::Directory, 'app/**/*.php'), + ]), ContainerStub::getLogger()); + + $files = $driver->matchingFilesForTest( + $matching . "\0" . $unmatched . "\0" . $unterminated, + ); + + $this->assertSame([$matching => true], $files); + } + + public function testPreservesParentSegmentsWhenMatchingASiblingPath(): void + { + $siblingBase = 'app/../sibling'; + $file = base_path($siblingBase . '/File.php'); + $driver = new ScriptedFindDriver(new Option(watchPaths: [ + new WatchPath($siblingBase, WatchPathType::Directory, $siblingBase . '/*.php'), + ]), ContainerStub::getLogger()); - $driver->findForTest($targets); + $this->assertSame([$file => true], $driver->matchingFilesForTest($file . "\0")); + } + + public function testGroupsIdenticalTargetsOnceAndLetsRecursiveTraversalWin(): void + { + $option = new Option(watchPaths: [ + new WatchPath('.', WatchPathType::Directory, '.env*'), + new WatchPath('.', WatchPathType::Directory), + new WatchPath('composer.json', WatchPathType::File), + new WatchPath('composer.json', WatchPathType::File), + ]); + $driver = new ScriptedFindDriver( + $option, + ContainerStub::getLogger(), + array_fill(0, 4, ['code' => 0, 'output' => '']), + ); + + try { + $driver->ensureReferenceFilesForTest(); + $driver->scanForTest(); + + $commands = array_slice($driver->commands, 1); + $this->assertCount(4, $commands); + $this->assertStringContainsString(escapeshellarg(base_path('composer.json')), $commands[0]); + $this->assertStringContainsString(' -maxdepth 1 ', $commands[0]); + $this->assertStringContainsString(escapeshellarg(base_path()), $commands[2]); + $this->assertStringNotContainsString(' -maxdepth 1 ', $commands[2]); + $this->assertSame(1, substr_count($commands[0], escapeshellarg(base_path('composer.json')))); + $this->assertSame(1, substr_count($commands[2], escapeshellarg(base_path()))); + } finally { + $driver->removeReferenceFilesForTest(); + } + } + + public function testMissingTargetIsACompleteEmptyInventoryAndIsDetectedWhenItAppears(): void + { + $path = base_path('late-watcher-target.php'); + @unlink($path); + $driver = new ScriptedFindDriver( + new Option(watchPaths: [new WatchPath('late-watcher-target.php', WatchPathType::File)]), + ContainerStub::getLogger(), + [ + ['code' => 0, 'output' => $path . "\0"], + ['code' => 0, 'output' => $path . "\0"], + ], + ); + + try { + $driver->ensureReferenceFilesForTest(); + + $this->assertSame([ + 'files' => [], + 'changedComplete' => true, + 'inventoryComplete' => true, + 'failureCode' => null, + ], $driver->scanForTest()); + $this->assertTrue($driver->hasCompleteInventoryForTest()); + $this->assertCount(1, $driver->commands); + + touch($path); + + $this->assertSame([ + 'files' => [$path], + 'changedComplete' => true, + 'inventoryComplete' => true, + 'failureCode' => null, + ], $driver->scanForTest()); + $this->assertCount(3, $driver->commands); + } finally { + @unlink($path); + $driver->removeReferenceFilesForTest(); + } + } + + public function testFirstCompleteInventoryEstablishesASilentBaseline(): void + { + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $existing = base_path('app/Existing.php'); + + $changes = $driver->reconcileForTest([], $this->set($existing), complete: true); + + $this->assertSame([], $changes); + $this->assertSame($this->set($existing), $driver->inventoryForTest()); + $this->assertTrue($driver->hasCompleteInventoryForTest()); + } + + public function testFirstCompleteInventoryPublishesOnlyFilesAlsoProvedChanged(): void + { + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $existing = base_path('app/Existing.php'); + $created = base_path('app/Created.php'); + + $changes = $driver->reconcileForTest( + $this->set($created), + $this->set($existing, $created), + complete: true, + ); + + $this->assertSame([$created], $changes); + $this->assertSame($this->set($existing, $created), $driver->inventoryForTest()); + } + + public function testCompleteInventoryPublishesAdditionsDeletionsAndModificationsOnce(): void + { + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $unchanged = base_path('app/Unchanged.php'); + $modified = base_path('app/Modified.php'); + $deleted = base_path('app/Deleted.php'); + $added = base_path('app/Added.php'); + $driver->reconcileForTest([], $this->set($unchanged, $modified, $deleted), complete: true); + + $changes = $driver->reconcileForTest( + $this->set($modified, $added), + $this->set($unchanged, $modified, $added), + complete: true, + ); + + $this->assertSame([$added, $deleted, $modified], $changes); + $this->assertSame($this->set($unchanged, $modified, $added), $driver->inventoryForTest()); + } + + public function testRenameIsDetectedByInventoryWhenModificationTimeDoesNotChange(): void + { + $directory = $this->tempDir . '/rename'; + $oldPath = $directory . '/old.php'; + $newPath = $directory . '/new.php'; + mkdir($directory); + file_put_contents($oldPath, 'contents'); + $driver = new RawOutputFindDriver($this->option(), ContainerStub::getLogger()); + + try { + $driver->ensureReferenceFilesForTest(); + [$initialInventory, $initialExitCode] = $driver->findForTest( + [$directory], + recursive: true, + changed: false, + ); + + $this->assertSame(0, $initialExitCode); + $this->assertSame([], $driver->reconcileForTest([], $initialInventory, complete: true)); + + rename($oldPath, $newPath); + + [$changedFiles, $changedExitCode] = $driver->findForTest( + [$directory], + recursive: true, + changed: true, + ); + [$currentInventory, $inventoryExitCode] = $driver->findForTest( + [$directory], + recursive: true, + changed: false, + ); + + $this->assertSame(0, $changedExitCode); + $this->assertSame([], $changedFiles); + $this->assertSame(0, $inventoryExitCode); + $this->assertEqualsCanonicalizing( + [$oldPath, $newPath], + $driver->reconcileForTest($changedFiles, $currentInventory, complete: true), + ); + } finally { + $driver->removeReferenceFilesForTest(); + } + } + + public function testChangedPathDeletedBeforeInventoryIsReportedOnlyAsADeletion(): void + { + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $path = base_path('app/ChangedThenDeleted.php'); + $driver->reconcileForTest([], $this->set($path), complete: true); $this->assertSame( - ($driver->isDarwin() ? 'gfind' : 'find') . ' ' - . implode(' ', array_map(escapeshellarg(...), $targets)) - . ' -mmin -0.10 -type f -print', - $driver->capturedCommand, + [$path], + $driver->reconcileForTest($this->set($path), [], complete: true), ); } - public function testNonGnuFindUsesWholeMinuteIntervals(): void + public function testCreationBetweenChangedAndInventoryPassesRemainsEligibleNextCycle(): void { - $option = new Option(driver: FindDriver::class, watchPaths: [], scanInterval: 1000); - $driver = new class($option, ContainerStub::getLogger()) extends FindDriver { - protected function exec(string $command): array - { - return $command === 'which find' - ? ['code' => 0, 'output' => '/usr/bin/find'] - : ['code' => 1, 'output' => 'BusyBox']; - } + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $path = base_path('app/CreatedBetweenPasses.php'); + $driver->reconcileForTest([], [], complete: true); - public function intervalForTest(): string - { - return $this->getScanIntervalMinutes(); - } - }; + $this->assertSame( + [$path], + $driver->reconcileForTest([], $this->set($path), complete: true), + ); + $this->assertSame( + [$path], + $driver->reconcileForTest($this->set($path), $this->set($path), complete: true), + ); + } - $this->assertSame('-1', $driver->intervalForTest()); + public function testIncompleteInventoryPublishesChangedFilesButNeverInventsDeletions(): void + { + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $existing = base_path('app/Existing.php'); + $changed = base_path('app/Changed.php'); + $driver->reconcileForTest([], $this->set($existing), complete: true); + + $changes = $driver->reconcileForTest( + $this->set($changed), + [], + complete: false, + ); + + $this->assertSame([$changed], $changes); + $this->assertSame($this->set($existing, $changed), $driver->inventoryForTest()); } - public function testFailedScanWarnsOnceAndProcessesValidOutput(): void + public function testRecoveryAfterFailedFirstInventoryAvoidsABaselineFloodAndKeepsRealDeletion(): void { - $file = $this->tempDir . '/changed.php'; - touch($file); + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $changed = base_path('app/Changed.php'); + $untouched = base_path('app/Untouched.php'); + + $this->assertSame( + [$changed], + $driver->reconcileForTest($this->set($changed), [], complete: false), + ); + $this->assertFalse($driver->hasCompleteInventoryForTest()); + + $this->assertSame( + [$changed], + $driver->reconcileForTest([], $this->set($untouched), complete: true), + ); + $this->assertSame($this->set($untouched), $driver->inventoryForTest()); + } + + public function testEmptyCompleteInventoryDeletesRetainedPathsAndBoundsState(): void + { + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + + for ($index = 0; $index < 100; ++$index) { + $path = base_path("app/{$index}.php"); + $driver->reconcileForTest($this->set($path), $this->set($path), complete: true); + $this->assertSame([$path], $driver->reconcileForTest([], [], complete: true)); + } + + $this->assertSame([], $driver->inventoryForTest()); + } + public function testChangedTraversalFailurePublishesCompleteRecordsAndHoldsTheCutoff(): void + { + $path = base_path('composer.json'); $logger = m::mock(StdoutLoggerInterface::class); $logger->shouldReceive('warning') ->once() - ->with('One or more find commands exited with code 1 while scanning watched paths.'); + ->with( + 'One or more find commands exited with code 1. ' + . 'Detected changes may repeat until the filesystem error is fixed.', + ); + $driver = new SingleCycleFindDriver( + new Option(watchPaths: [new WatchPath('composer.json', WatchPathType::File)]), + $logger, + [ + ['code' => 1, 'output' => $path . "\0" . base_path('unterminated.php')], + ['code' => 0, 'output' => $path . "\0"], + ], + ); + $channel = new Channel(1); - $option = new Option( - driver: FindDriver::class, - watchPaths: [new WatchPath('changed.php', WatchPathType::File)], - scanInterval: 1000, + try { + $driver->watch($channel); + + $this->assertSame($path, $channel->pop(0.1)); + $this->assertSame(0, $driver->rotationCount); + $this->assertSame([], $driver->referenceFilesForTest()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testInventoryFailureSuspendsDeletionsButAdvancesACompleteChangedCutoff(): void + { + $path = base_path('composer.json'); + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('warning') + ->once() + ->with( + 'One or more find commands exited with code 2. ' + . 'Deletion detection is suspended until the filesystem error is fixed.', + ); + $driver = new SingleCycleFindDriver( + new Option(watchPaths: [new WatchPath('composer.json', WatchPathType::File)]), + $logger, + [ + ['code' => 0, 'output' => $path . "\0"], + ['code' => 2, 'output' => ''], + ], + ); + $channel = new Channel(1); + + try { + $driver->watch($channel); + + $this->assertSame($path, $channel->pop(0.1)); + $this->assertSame(1, $driver->rotationCount); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testMultipleTraversalFailuresLogTheFirstCodeAndBothAffectedGuaranteesOnce(): void + { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('warning') + ->once() + ->with( + 'One or more find commands exited with code 2. ' + . 'Detected changes may repeat until the filesystem error is fixed. ' + . 'Deletion detection is suspended until the filesystem error is fixed.', + ); + $driver = new SingleCycleFindDriver( + new Option(watchPaths: [ + new WatchPath('composer.json', WatchPathType::File), + new WatchPath('.', WatchPathType::Directory), + ]), + $logger, + [ + ['code' => 2, 'output' => ''], + ['code' => 3, 'output' => ''], + ['code' => 4, 'output' => ''], + ['code' => 5, 'output' => ''], + ], ); - $driver = new class($option, $logger, $this->tempDir, $file) extends FindDriver { + $channel = new Channel(1); + + try { + $driver->watch($channel); + + $this->assertSame(0, $driver->rotationCount); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testReferenceFilesAreCreatedForTheLifecycleAndUniquePerDriver(): void + { + $first = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $second = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + + $this->assertSame([], $first->referenceFilesForTest()); + $this->assertSame([], $second->referenceFilesForTest()); + + try { + $first->ensureReferenceFilesForTest(); + $second->ensureReferenceFilesForTest(); + $files = [...$first->referenceFilesForTest(), ...$second->referenceFilesForTest()]; + + $this->assertCount(4, array_unique($files)); + foreach ($files as $file) { + $this->assertFileExists($file); + } + } finally { + $first->removeReferenceFilesForTest(); + $second->removeReferenceFilesForTest(); + } + } + + public function testStoppedDriverDoesNotCreateAnotherReferenceLifecycle(): void + { + $driver = new ScriptedFindDriver($this->option(), ContainerStub::getLogger()); + $channel = new Channel(1); + + try { + $driver->stop(); + $driver->stop(); + $driver->watch($channel); + + $this->assertSame([], $driver->referenceFilesForTest()); + $this->assertSame(['command -v find'], $driver->commands); + } finally { + $channel->close(); + } + } + + public function testSecondReferenceCreationFailureRemovesTheFirstFile(): void + { + $createdFiles = []; + $driver = new class($this->option(), ContainerStub::getLogger(), $createdFiles) extends FindDriver { + private int $creationCount = 0; + + /** @var list */ + private array $createdFiles; + public function __construct( Option $option, StdoutLoggerInterface $logger, - private string $target, - private string $file, + array &$createdFiles, ) { + $this->createdFiles = &$createdFiles; + parent::__construct($option, $logger); } protected function exec(string $command): array { - if ($command === 'which find') { - return ['code' => 0, 'output' => '/usr/bin/find']; - } + return ['code' => 0, 'output' => '/usr/bin/find']; + } - if ($command === 'find --version') { - return ['code' => 0, 'output' => 'GNU findutils']; + protected function createReferenceFile(): string + { + if (++$this->creationCount === 2) { + throw new RuntimeException('Unable to create a watcher reference file.'); } - return ['code' => 1, 'output' => $this->file]; + $path = parent::createReferenceFile(); + $this->createdFiles[] = $path; + + return $path; + } + + public function ensureReferenceFilesForTest(): void + { + $this->ensureReferenceFiles(); + } + + public function referenceFilesForTest(): array + { + return $this->referenceFiles; + } + }; + $exception = null; + + try { + $driver->ensureReferenceFilesForTest(); + } catch (RuntimeException $caughtException) { + $exception = $caughtException; + } + + $this->assertSame('Unable to create a watcher reference file.', $exception?->getMessage()); + $this->assertCount(1, $createdFiles); + $this->assertFileDoesNotExist($createdFiles[0]); + $this->assertSame([], $driver->referenceFilesForTest()); + } + + public function testTouchFailureTerminatesTheLifecycleAndCleansReferences(): void + { + $driver = new class($this->option(), ContainerStub::getLogger()) extends FindDriver { + /** @var list */ + public array $createdFiles = []; + + protected function exec(string $command): array + { + return ['code' => 0, 'output' => '/usr/bin/find']; } - protected function resolveTargets(array $watchPaths): array + protected function createReferenceFile(): string { - return $watchPaths === [] ? [] : [$this->target]; + $path = parent::createReferenceFile(); + $this->createdFiles[] = $path; + + return $path; } - public function scanForTest(): array + protected function updateReferenceFile(string $path): void { - $this->startTime = 0; + throw new RuntimeException('touch failed'); + } - return $this->scan([], '-1'); + public function referenceFilesForTest(): array + { + return $this->referenceFiles; } }; + $exception = null; + $channel = new Channel(1); - [, $changedFiles] = $driver->scanForTest(); + try { + $driver->watch($channel); + } catch (RuntimeException $caughtException) { + $exception = $caughtException; + } finally { + $channel->close(); + } - $this->assertSame([$file], $changedFiles); + $this->assertSame('touch failed', $exception?->getMessage()); + $this->assertSame([], $driver->referenceFilesForTest()); + foreach ($driver->createdFiles as $file) { + $this->assertFileDoesNotExist($file); + } } - public function testFilesChangedInTheStartingSecondAreReported(): void + public function testScanExceptionTerminatesTheLifecycleAndCleansReferences(): void { - $file = $this->tempDir . '/changed.php'; - touch($file); - $modifiedAt = filemtime($file); - $this->assertIsInt($modifiedAt); + $driver = new class($this->option(), ContainerStub::getLogger()) extends FindDriver { + /** @var list */ + public array $createdFiles = []; + + protected function exec(string $command): array + { + return ['code' => 0, 'output' => '/usr/bin/find']; + } + + protected function createReferenceFile(): string + { + $path = parent::createReferenceFile(); + $this->createdFiles[] = $path; + + return $path; + } + + protected function scan(): array + { + throw new RuntimeException('scan failed'); + } + + public function referenceFilesForTest(): array + { + return $this->referenceFiles; + } + }; + $exception = null; + $channel = new Channel(1); + + try { + $driver->watch($channel); + } catch (RuntimeException $caughtException) { + $exception = $caughtException; + } finally { + $channel->close(); + } + + $this->assertSame('scan failed', $exception?->getMessage()); + $this->assertSame([], $driver->referenceFilesForTest()); + foreach ($driver->createdFiles as $file) { + $this->assertFileDoesNotExist($file); + } + } + + public function testStopDuringReferenceUpdateSkipsTheScanAndCleansAfterOwnershipReturns(): void + { + $entered = new Channel(1); + $resume = new Channel(1); + $output = new Channel(1); + $driver = new class($this->option(), ContainerStub::getLogger(), $entered, $resume) extends FindDriver { + public bool $scanCalled = false; + + /** @var list */ + public array $filesDuringUpdate = []; - $driver = new class($this->option(), ContainerStub::getLogger(), $file, $modifiedAt) extends FindDriver { public function __construct( Option $option, StdoutLoggerInterface $logger, - private string $file, - private int $modifiedAt, + private Channel $entered, + private Channel $resume, ) { parent::__construct($option, $logger); } protected function exec(string $command): array { - if ($command === 'which find') { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - if ($command === 'find --version') { - return ['code' => 0, 'output' => 'GNU findutils']; - } + return ['code' => 0, 'output' => '/usr/bin/find']; + } - return ['code' => 0, 'output' => $this->file]; + protected function updateReferenceFile(string $path): void + { + $this->filesDuringUpdate = $this->referenceFiles; + $this->entered->push(true); + $this->resume->pop(); } - public function findForTest(): array + protected function scan(): array { - $this->startTime = $this->modifiedAt; + $this->scanCalled = true; + + return [ + 'files' => [], + 'changedComplete' => true, + 'inventoryComplete' => true, + 'failureCode' => null, + ]; + } - return $this->find([], [$this->file], '-1'); + public function referenceFilesForTest(): array + { + return $this->referenceFiles; } }; + $finished = new WaitGroup(1); - [, $changedFiles] = $driver->findForTest(); + Coroutine::create(function () use ($driver, $finished, $output): void { + try { + $driver->watch($output); + } finally { + $finished->done(); + } + }); + + try { + $this->assertTrue($entered->pop(0.1)); + $driver->stop(); + + foreach ($driver->filesDuringUpdate as $file) { + $this->assertFileExists($file); + } - $this->assertSame([$file], $changedFiles); + $resume->push(true); + $this->assertTrue($finished->wait(0.1)); + $this->assertFalse($driver->scanCalled); + $this->assertSame([], $driver->referenceFilesForTest()); + } finally { + $driver->stop(); + $entered->close(); + $resume->close(); + $output->close(); + } } - public function testFileDisappearanceDuringMetadataReadIsIgnored(): void + public function testStopDuringScanPublishesNothingDoesNotRotateAndCleansAfterTheCommandReturns(): void { - $missing = $this->tempDir . '/missing.php'; - $driver = new class($this->option(), ContainerStub::getLogger(), $missing) extends FindDriver { + $entered = new Channel(1); + $resume = new Channel(1); + $output = new Channel(1); + $driver = new class($this->option(), ContainerStub::getLogger(), $entered, $resume) extends FindDriver { + public int $rotationCount = 0; + + /** @var list */ + public array $filesDuringScan = []; + public function __construct( Option $option, StdoutLoggerInterface $logger, - private string $missing, + private Channel $entered, + private Channel $resume, ) { parent::__construct($option, $logger); } protected function exec(string $command): array { - if ($command === 'which find') { - return ['code' => 0, 'output' => '/usr/bin/find']; - } + return ['code' => 0, 'output' => '/usr/bin/find']; + } - if ($command === 'find --version') { - return ['code' => 0, 'output' => 'GNU findutils']; - } + protected function scan(): array + { + $this->filesDuringScan = $this->referenceFiles; + $this->entered->push(true); + $this->resume->pop(); + + return [ + 'files' => ['/tmp/changed.php'], + 'changedComplete' => true, + 'inventoryComplete' => true, + 'failureCode' => null, + ]; + } - return ['code' => 0, 'output' => $this->missing]; + protected function rotateReferenceFiles(): void + { + ++$this->rotationCount; + parent::rotateReferenceFiles(); } - public function findForTest(): array + public function referenceFilesForTest(): array { - return $this->find([], [$this->missing], '-1'); + return $this->referenceFiles; } }; + $finished = new WaitGroup(1); + + Coroutine::create(function () use ($driver, $finished, $output): void { + try { + $driver->watch($output); + } finally { + $finished->done(); + } + }); + + try { + $this->assertTrue($entered->pop(0.1)); + $driver->stop(); - [, $changedFiles] = $driver->findForTest(); + foreach ($driver->filesDuringScan as $file) { + $this->assertFileExists($file); + } - $this->assertSame([], $changedFiles); + $resume->push(true); + $this->assertTrue($finished->wait(0.1)); + $this->assertFalse($output->pop(0.01)); + $this->assertSame(0, $driver->rotationCount); + $this->assertSame([], $driver->referenceFilesForTest()); + } finally { + $driver->stop(); + $entered->close(); + $resume->close(); + $output->close(); + } } - public function testMissingTargetsAreSkippedAndAdoptedWhenTheyAppear(): void + public function testCutoffRecordedBeforeASlowScanKeepsLateChangesEligible(): void { - $missing = $this->tempDir . '/missing.php'; - $late = $this->tempDir . '/late.php'; - $option = new Option( - driver: FindDriver::class, - watchPaths: [ - new WatchPath('missing.php', WatchPathType::File), - new WatchPath('late.php', WatchPathType::File), - ], - scanInterval: 1000, - ); - $driver = new class($option, ContainerStub::getLogger(), $missing, $late) extends FindDriver { - public int $scanCommands = 0; + $completed = new Channel(1); + $output = new Channel(1); + $driver = new class(new Option(scanInterval: 1), ContainerStub::getLogger(), $completed) extends FindDriver { + /** @var array */ + public array $cutoffs = []; + + /** @var list */ + public array $scanReferences = []; - public string $lastCommand = ''; + /** @var list */ + public array $updatedReferences = []; + + public bool $lateChangeEligible = false; + + private int $clock = 0; + + private int $lateChangeAt = 0; + + private int $scanCount = 0; public function __construct( Option $option, StdoutLoggerInterface $logger, - private string $missing, - private string $late, + private Channel $completed, ) { parent::__construct($option, $logger); } protected function exec(string $command): array { - if (str_starts_with($command, 'which ')) { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - if (str_ends_with($command, ' --version')) { - return ['code' => 0, 'output' => 'GNU findutils']; - } + return ['code' => 0, 'output' => '/usr/bin/find']; + } - ++$this->scanCommands; - $this->lastCommand = $command; + protected function createReferenceFile(): string + { + $path = parent::createReferenceFile(); + $this->cutoffs[$path] = 0; - return ['code' => 0, 'output' => $this->late]; + return $path; } - protected function resolveTargets(array $watchPaths): array + protected function updateReferenceFile(string $path): void { - return $watchPaths === [] ? [] : [$this->missing, $this->late]; + $this->updatedReferences[] = $path; + $this->cutoffs[$path] = ++$this->clock; } - public function scanForTest(): array + protected function scan(): array { - $this->startTime = 0; + $reference = $this->activeReferenceFile(); + $this->scanReferences[] = $reference; + + if (++$this->scanCount === 1) { + $this->lateChangeAt = ++$this->clock; + + return [ + 'files' => [], + 'changedComplete' => true, + 'inventoryComplete' => true, + 'failureCode' => null, + ]; + } + + $this->lateChangeEligible = $this->lateChangeAt > $this->cutoffs[$reference]; + $this->completed->push(true); + $this->stop(); - return $this->scan([], '-1'); + return [ + 'files' => [], + 'changedComplete' => true, + 'inventoryComplete' => true, + 'failureCode' => null, + ]; } }; - [, $initialChanges] = $driver->scanForTest(); - $this->assertSame([], $initialChanges); - $this->assertSame(0, $driver->scanCommands); + try { + $driver->watch($output); + + $this->assertTrue($completed->pop(0.1)); + $this->assertTrue($driver->lateChangeEligible); + $this->assertCount(2, $driver->updatedReferences); + $this->assertCount(2, $driver->scanReferences); + $this->assertSame($driver->updatedReferences[0], $driver->scanReferences[1]); + $this->assertSame($driver->scanReferences[0], $driver->updatedReferences[1]); + } finally { + $driver->stop(); + $completed->close(); + $output->close(); + } + } - touch($late); + public function testFindFollowsASymlinkOperandButNotSymlinksFoundDuringDescent(): void + { + $realDirectory = $this->tempDir . '/real'; + $externalDirectory = $this->tempDir . '/external'; + $linkDirectory = $this->tempDir . '/root-link'; + mkdir($realDirectory); + mkdir($externalDirectory); + touch($realDirectory . '/direct.php'); + touch($externalDirectory . '/nested.php'); + symlink($externalDirectory, $realDirectory . '/nested-link'); + symlink($realDirectory, $linkDirectory); + $driver = new RawOutputFindDriver($this->option(), ContainerStub::getLogger()); - [, $changedFiles] = $driver->scanForTest(); + try { + $driver->ensureReferenceFilesForTest(); + [$files, $exitCode] = $driver->findForTest([$linkDirectory], recursive: true, changed: false); - $this->assertSame([$late], $changedFiles); - $this->assertSame(1, $driver->scanCommands); - $this->assertStringNotContainsString($missing, $driver->lastCommand); + $this->assertSame(0, $exitCode); + $this->assertArrayHasKey($linkDirectory . '/direct.php', $files); + $this->assertArrayNotHasKey($linkDirectory . '/nested-link/nested.php', $files); + } finally { + $driver->removeReferenceFilesForTest(); + } } /** @@ -348,6 +991,155 @@ public function scanForTest(): array */ private function option(): Option { - return new Option(driver: FindDriver::class, watchPaths: [], scanInterval: 1000); + return new Option(driver: FindDriver::class, scanInterval: 1); + } + + /** + * Create a string-keyed set. + * + * @return array + */ + private function set(string ...$paths): array + { + return array_fill_keys($paths, true); + } +} + +class ScriptedFindDriver extends FindDriver +{ + /** @var list */ + protected array $results; + + /** @var list */ + public array $commands = []; + + public int $rotationCount = 0; + + /** + * @param list $results + */ + public function __construct( + Option $option, + StdoutLoggerInterface $logger, + array $results = [], + ) { + $this->results = $results; + + parent::__construct($option, $logger); + } + + protected function exec(string $command): array + { + $this->commands[] = $command; + + if ($command === 'command -v find') { + return ['code' => 0, 'output' => '/usr/bin/find']; + } + + return array_shift($this->results) ?? ['code' => 0, 'output' => '']; + } + + protected function rotateReferenceFiles(): void + { + ++$this->rotationCount; + parent::rotateReferenceFiles(); + } + + public function ensureReferenceFilesForTest(): void + { + $this->ensureReferenceFiles(); + } + + public function removeReferenceFilesForTest(): void + { + $this->removeReferenceFiles(); + } + + public function referenceFilesForTest(): array + { + return $this->referenceFiles; + } + + public function activeReferenceForTest(): string + { + return $this->activeReferenceFile(); + } + + public function findForTest(array $targets, bool $recursive, bool $changed): array + { + return $this->find($targets, $recursive, $changed); + } + + public function matchingFilesForTest(string $output): array + { + return $this->matchingFiles($output); + } + + public function scanForTest(): array + { + return $this->scan(); + } + + public function reconcileForTest(array $changedFiles, array $inventory, bool $complete): array + { + return $this->reconcileInventory($changedFiles, $inventory, $complete); + } + + public function inventoryForTest(): array + { + return $this->inventory; + } + + public function hasCompleteInventoryForTest(): bool + { + return $this->hasCompleteInventory; + } +} + +class SingleCycleFindDriver extends ScriptedFindDriver +{ + protected function watchAtInterval(float $seconds, callable $scan): void + { + $scan(); + } +} + +class RawOutputFindDriver extends FindDriver +{ + protected function matchingFiles(string $output): array + { + $files = []; + $offset = 0; + + while (($separator = strpos($output, "\0", $offset)) !== false) { + $file = substr($output, $offset, $separator - $offset); + $offset = $separator + 1; + + if ($file !== '') { + $files[$file] = true; + } + } + + return $files; + } + + public function ensureReferenceFilesForTest(): void + { + $this->ensureReferenceFiles(); + } + + public function removeReferenceFilesForTest(): void + { + $this->removeReferenceFiles(); + } + + public function findForTest(array $targets, bool $recursive, bool $changed): array + { + return $this->find($targets, $recursive, $changed); + } + + public function reconcileForTest(array $changedFiles, array $inventory, bool $complete): array + { + return $this->reconcileInventory($changedFiles, $inventory, $complete); } } diff --git a/tests/Watcher/Driver/FindNewerDriverTest.php b/tests/Watcher/Driver/FindNewerDriverTest.php deleted file mode 100644 index 20e5fa34ce..0000000000 --- a/tests/Watcher/Driver/FindNewerDriverTest.php +++ /dev/null @@ -1,755 +0,0 @@ -watch($channel); - } finally { - $finished->done(); - } - }); - $this->assertSame('.env', $channel->pop($option->getScanIntervalSeconds() + 0.1)); - } catch (InvalidArgumentException $e) { - if (str_contains($e->getMessage(), 'find not exists')) { - $this->markTestSkipped(); - } - throw $e; - } finally { - if (isset($driver)) { - $driver->stop(); - } - if (isset($finished)) { - $this->assertTrue($finished->wait(0.1)); - } - $channel->close(); - } - } - - public function testScanExceptionTerminatesTheWatchLifecycle(): void - { - $option = new Option( - driver: FindNewerDriver::class, - watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), - ], - scanInterval: 1, - ); - - $driver = new class($option, ContainerStub::getLogger()) extends FindNewerDriver { - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - protected function scan(): array - { - throw new RuntimeException('Simulated scan failure'); - } - }; - - $channel = new Channel(10); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Simulated scan failure'); - - try { - $driver->watch($channel); - } finally { - $driver->stop(); - $channel->close(); - } - } - - public function testAllChangedFilesAreReported(): void - { - $option = new Option( - driver: FindNewerDriver::class, - watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), - ], - scanInterval: 1, - ); - - // Stub that returns multiple files on first tick, then empty. - // This prevents the timer from continuously filling the channel. - $driver = new class($option, ContainerStub::getLogger()) extends FindNewerDriver { - private int $scanCallCount = 0; - - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - protected function scan(): array - { - if (++$this->scanCallCount === 1) { - return [['/tmp/a.php', '/tmp/b.php', '/tmp/c.php'], null]; - } - - $this->stop(); - - return [[], null]; - } - }; - - $channel = new Channel(10); - $driver->watch($channel); - - try { - // Collect all pushed files from the first tick. - $pushed = []; - $timeout = 0.2; - while (($file = $channel->pop($timeout)) !== false) { - $pushed[] = $file; - $timeout = 0.05; - } - - $this->assertContains('/tmp/a.php', $pushed); - $this->assertContains('/tmp/b.php', $pushed); - $this->assertContains('/tmp/c.php', $pushed); - $this->assertCount(3, $pushed); - } finally { - $driver->stop(); - $channel->close(); - } - } - - public function testFailedScanReportsFilesWithoutAdvancingTheReference(): void - { - $logger = m::mock(StdoutLoggerInterface::class); - $logger->shouldReceive('warning') - ->once() - ->with('One or more find commands exited with code 1 while scanning watched paths.'); - - $driver = new class($this->option(), $logger) extends FindNewerDriver { - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - protected function watchAtInterval(float $seconds, callable $scan): void - { - $scan(); - } - - protected function scan(): array - { - return [['/tmp/changed.php'], 1]; - } - - public function referenceRoleForTest(): int - { - return $this->count; - } - }; - $channel = new Channel(1); - - try { - $driver->watch($channel); - - $this->assertSame('/tmp/changed.php', $channel->pop(0.1)); - $this->assertSame(0, $driver->referenceRoleForTest()); - } finally { - $driver->stop(); - $channel->close(); - } - } - - public function testFindEscapesTargetsAndTheReferencePath(): void - { - $driver = new class($this->option(), ContainerStub::getLogger()) extends FindNewerDriver { - public string $capturedCommand = ''; - - protected function exec(string $command): array - { - if ($command === 'which find') { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - $this->capturedCommand = $command; - - return ['code' => 0, 'output' => '']; - } - - public function scanReferenceForTest(): string - { - return $this->getToScanFile(); - } - - public function findForTest(array $targets): void - { - $this->find($targets); - } - }; - $targets = ['/tmp/path with spaces', "/tmp/path'quoted", '/tmp/$(ignored);touch nope']; - - try { - $driver->findForTest($targets); - - $reference = escapeshellarg($driver->scanReferenceForTest()); - $expected = 'find ' . implode(' ', array_map(escapeshellarg(...), $targets)) - . " -newer {$reference} -type f -print"; - $this->assertSame($expected, $driver->capturedCommand); - } finally { - $driver->stop(); - } - } - - public function testFindProcessesOutputWhenTheCommandFails(): void - { - $driver = new class($this->option(), ContainerStub::getLogger()) extends FindNewerDriver { - protected function exec(string $command): array - { - if ($command === 'which find') { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - return ['code' => 1, 'output' => "/tmp/a.php\n/tmp/b.php\n"]; - } - - public function findForTest(): array - { - return $this->find(['/tmp']); - } - }; - - try { - [$changedFiles, $exitCode] = $driver->findForTest(); - - $this->assertSame(['/tmp/a.php', '/tmp/b.php'], $changedFiles); - $this->assertSame(1, $exitCode); - } finally { - $driver->stop(); - } - } - - public function testReferenceFileUpdateCreatesAndBumpsTheFile(): void - { - $driver = new class($this->option(), ContainerStub::getLogger()) extends FindNewerDriver { - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - public function referenceFileForTest(): string - { - return $this->getToModifyFile(); - } - - public function updateForTest(string $path): void - { - parent::updateReferenceFile($path); - } - }; - $path = $driver->referenceFileForTest(); - - try { - $this->assertFileExists($path); - - touch($path, 1); - clearstatcache(true, $path); - $driver->updateForTest($path); - clearstatcache(true, $path); - - $this->assertGreaterThan(1, filemtime($path)); - } finally { - $driver->stop(); - } - } - - public function testReferenceFileUpdateThrowsWhenTouchFails(): void - { - $driver = new class($this->option(), ContainerStub::getLogger()) extends FindNewerDriver { - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - public function updateForTest(string $path): void - { - parent::updateReferenceFile($path); - } - }; - $path = sys_get_temp_dir() . '/missing-hypervel-watcher-directory-' . getmypid() . '/reference'; - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage("Unable to update the watcher reference file [{$path}]."); - - try { - $driver->updateForTest($path); - } finally { - $driver->stop(); - } - } - - public function testReferenceFilesAreUniquePerDriverAndRemovedOnStop(): void - { - $first = $this->referenceFileDriver(); - $second = $this->referenceFileDriver(); - $firstFiles = $first->referenceFilesForTest(); - $secondFiles = $second->referenceFilesForTest(); - - $this->assertCount(2, $firstFiles); - $this->assertCount(2, $secondFiles); - $this->assertCount(4, array_unique([...$firstFiles, ...$secondFiles])); - - foreach ([...$firstFiles, ...$secondFiles] as $file) { - $this->assertFileExists($file); - } - - $first->stop(); - $first->stop(); - $second->stop(); - - foreach ([...$firstFiles, ...$secondFiles] as $file) { - $this->assertFileDoesNotExist($file); - } - } - - public function testStoppedDriverCannotStartAnotherWatchLifecycle(): void - { - $driver = $this->referenceFileDriver(); - $oldFiles = $driver->referenceFilesForTest(); - $driver->stop(); - $channel = new Channel(1); - - try { - $driver->watch($channel); - $this->assertSame([], $driver->referenceFilesForTest()); - - foreach ($oldFiles as $file) { - $this->assertFileDoesNotExist($file); - } - } finally { - $driver->stop(); - $channel->close(); - } - } - - public function testChangeMadeDuringScanRemainsEligibleForTheNextScan(): void - { - $completed = new Channel(1); - $driver = new class($this->option(), $completed) extends FindNewerDriver { - /** @var array */ - public array $cutoffs = []; - - /** @var list */ - public array $scanReferences = []; - - /** @var list */ - public array $updatedReferences = []; - - public bool $lateChangeEligible = false; - - private int $clock = 0; - - private int $lateChangeAt = 0; - - private int $scanCount = 0; - - public function __construct(Option $option, private Channel $completed) - { - parent::__construct($option, ContainerStub::getLogger()); - } - - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - protected function createReferenceFile(): string - { - $path = parent::createReferenceFile(); - $this->cutoffs[$path] = 0; - - return $path; - } - - protected function updateReferenceFile(string $path): void - { - $this->updatedReferences[] = $path; - $this->cutoffs[$path] = ++$this->clock; - } - - protected function scan(): array - { - $reference = $this->getToScanFile(); - $this->scanReferences[] = $reference; - - if (++$this->scanCount === 1) { - // This change happens after the active scan has passed its path. - $this->lateChangeAt = ++$this->clock; - - return [['/tmp/another-change.php'], null]; - } - - $this->lateChangeEligible = $this->lateChangeAt > $this->cutoffs[$reference]; - $this->stop(); - $this->completed->push(true); - - return [$this->lateChangeEligible ? ['/tmp/late-change.php'] : [], null]; - } - }; - $output = new Channel(2); - - try { - $driver->watch($output); - $this->assertTrue($completed->pop(0.2)); - - $this->assertTrue($driver->lateChangeEligible); - $this->assertCount(2, $driver->updatedReferences); - $this->assertCount(2, $driver->scanReferences); - $this->assertSame($driver->updatedReferences[0], $driver->scanReferences[1]); - $this->assertSame($driver->scanReferences[0], $driver->updatedReferences[1]); - } finally { - $driver->stop(); - $completed->close(); - $output->close(); - } - } - - public function testQuietSuccessfulScanStillRotatesReferenceRoles(): void - { - $completed = new Channel(1); - $driver = new class($this->option(), $completed) extends FindNewerDriver { - /** @var list */ - public array $scanReferences = []; - - /** @var list */ - public array $updatedReferences = []; - - public function __construct(Option $option, private Channel $completed) - { - parent::__construct($option, ContainerStub::getLogger()); - } - - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - protected function updateReferenceFile(string $path): void - { - $this->updatedReferences[] = $path; - } - - protected function scan(): array - { - $this->scanReferences[] = $this->getToScanFile(); - - if (count($this->scanReferences) === 2) { - $this->stop(); - $this->completed->push(true); - } - - return [[], null]; - } - }; - $output = new Channel(1); - - try { - $driver->watch($output); - $this->assertTrue($completed->pop(0.2)); - - $this->assertCount(2, $driver->updatedReferences); - $this->assertCount(2, $driver->scanReferences); - $this->assertSame($driver->updatedReferences[0], $driver->scanReferences[1]); - $this->assertSame($driver->scanReferences[0], $driver->updatedReferences[1]); - } finally { - $driver->stop(); - $completed->close(); - $output->close(); - } - } - - public function testStopDuringAYieldingReferenceUpdateSkipsTheScanAndDefersCleanup(): void - { - $entered = new Channel(1); - $resume = new Channel(1); - $removed = new Channel(1); - $output = new Channel(1); - $driver = new class($this->option(), $entered, $resume, $removed) extends FindNewerDriver { - public bool $scanCalled = false; - - public int $updateCount = 0; - - public function __construct( - Option $option, - private Channel $entered, - private Channel $resume, - private Channel $removed, - ) { - parent::__construct($option, ContainerStub::getLogger()); - } - - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - protected function updateReferenceFile(string $path): void - { - ++$this->updateCount; - $this->entered->push(true); - $this->resume->pop(); - } - - protected function scan(): array - { - $this->scanCalled = true; - - return [[], null]; - } - - protected function removeReferenceFiles(): void - { - $hadFiles = $this->referenceFiles !== []; - - parent::removeReferenceFiles(); - - if ($hadFiles) { - $this->removed->push(true); - } - } - - public function referenceFilesForTest(): array - { - return $this->referenceFiles; - } - }; - $finished = new WaitGroup(1); - - try { - Coroutine::create(function () use ($driver, $finished, $output): void { - try { - $driver->watch($output); - } finally { - $finished->done(); - } - }); - $this->assertTrue($entered->pop(0.2)); - - $driver->stop(); - $resume->push(true); - - $this->assertTrue($removed->pop(0.2)); - $this->assertTrue($finished->wait(0.2)); - $this->assertSame(1, $driver->updateCount); - $this->assertFalse($driver->scanCalled); - $this->assertSame([], $driver->referenceFilesForTest()); - } finally { - $driver->stop(); - $entered->close(); - $resume->close(); - $removed->close(); - $output->close(); - } - } - - public function testStopDuringAYieldingScanDefersCleanupAndRejectsAnImmediateRestart(): void - { - $entered = new Channel(1); - $resume = new Channel(1); - $removed = new Channel(1); - $output = new Channel(1); - $driver = new class($this->option(), $entered, $resume, $removed) extends FindNewerDriver { - public int $updateCount = 0; - - public function __construct( - Option $option, - private Channel $entered, - private Channel $resume, - private Channel $removed, - ) { - parent::__construct($option, ContainerStub::getLogger()); - } - - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - protected function scan(): array - { - $this->entered->push(true); - $this->resume->pop(); - - return [['/tmp/changed.php'], null]; - } - - protected function updateReferenceFile(string $path): void - { - ++$this->updateCount; - - parent::updateReferenceFile($path); - } - - protected function removeReferenceFiles(): void - { - $hadFiles = $this->referenceFiles !== []; - - parent::removeReferenceFiles(); - - if ($hadFiles) { - $this->removed->push(true); - } - } - - public function referenceFilesForTest(): array - { - return $this->referenceFiles; - } - }; - $finished = new WaitGroup(1); - - try { - Coroutine::create(function () use ($driver, $finished, $output): void { - try { - $driver->watch($output); - } finally { - $finished->done(); - } - }); - $this->assertTrue($entered->pop(0.2)); - $files = $driver->referenceFilesForTest(); - - $driver->stop(); - - foreach ($files as $file) { - $this->assertFileExists($file); - } - - try { - $driver->watch($output); - $this->fail('Expected an immediate restart during scan shutdown to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame( - 'Cannot restart the find-newer watcher while its previous scan is still stopping.', - $exception->getMessage(), - ); - } - - $resume->push(true); - $this->assertTrue($removed->pop(0.2)); - $this->assertTrue($finished->wait(0.2)); - $this->assertSame(1, $driver->updateCount); - $this->assertSame([], $driver->referenceFilesForTest()); - - foreach ($files as $file) { - $this->assertFileDoesNotExist($file); - } - } finally { - $driver->stop(); - $entered->close(); - $resume->close(); - $removed->close(); - $output->close(); - } - } - - public function testSecondReferenceFileCreationFailureRemovesTheFirstFile(): void - { - $createdFiles = []; - - try { - new class($this->option(), $createdFiles) extends FindNewerDriver { - private int $creationCount = 0; - - /** @var list */ - private array $createdFiles; - - public function __construct(Option $option, array &$createdFiles) - { - $this->createdFiles = &$createdFiles; - - parent::__construct($option, ContainerStub::getLogger()); - } - - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - protected function createReferenceFile(): string - { - if (++$this->creationCount === 2) { - throw new RuntimeException('Unable to create a watcher reference file.'); - } - - $path = parent::createReferenceFile(); - $this->createdFiles[] = $path; - - return $path; - } - }; - - $this->fail('Expected reference-file creation to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame('Unable to create a watcher reference file.', $exception->getMessage()); - } - - $this->assertCount(1, $createdFiles); - $this->assertFileDoesNotExist($createdFiles[0]); - } - - /** - * Create standard find-newer options. - */ - private function option(): Option - { - return new Option(driver: FindNewerDriver::class, watchPaths: [], scanInterval: 1); - } - - /** - * Create a driver exposing its owned reference files. - */ - private function referenceFileDriver(): FindNewerDriver - { - return new class($this->option(), ContainerStub::getLogger()) extends FindNewerDriver { - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/find']; - } - - public function referenceFilesForTest(): array - { - return $this->referenceFiles; - } - }; - } -} diff --git a/tests/Watcher/Driver/FswatchDriverTest.php b/tests/Watcher/Driver/FswatchDriverTest.php index 1c8ee3bac2..5a2991c341 100644 --- a/tests/Watcher/Driver/FswatchDriverTest.php +++ b/tests/Watcher/Driver/FswatchDriverTest.php @@ -7,8 +7,8 @@ use Hypervel\Coroutine\WaitGroup; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; +use Hypervel\Filesystem\Filesystem; use Hypervel\Testbench\TestCase; -use Hypervel\Tests\Watcher\Fixtures\FswatchDriverStub; use Hypervel\Watcher\Driver\FswatchDriver; use Hypervel\Watcher\Option; use Hypervel\Watcher\WatchPath; @@ -19,88 +19,87 @@ class FswatchDriverTest extends TestCase { - public function testWatch(): void + protected Filesystem $files; + + protected string $fixturePath; + + protected string $fixtureRelativePath = 'fswatch-driver-test'; + + protected function setUp(): void { - $option = new Option( - driver: FswatchDriver::class, - watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), - new WatchPath('.env', WatchPathType::File), - ], - scanInterval: 1, - ); + parent::setUp(); - $channel = new Channel(10); + $this->files = new Filesystem; + $this->fixturePath = base_path($this->fixtureRelativePath); + $this->files->deleteDirectory($this->fixturePath); + $this->files->makeDirectory($this->fixturePath); + } - try { - $driver = new FswatchDriverStub($option); - $finished = new WaitGroup(1); - Coroutine::create(function () use ($channel, $driver, $finished): void { - try { - $driver->watch($channel); - } finally { - $finished->done(); - } - }); + protected function tearDown(): void + { + $this->files->deleteDirectory($this->fixturePath); - $this->assertSame('.env', $channel->pop($option->getScanIntervalSeconds() + 0.1)); - } catch (InvalidArgumentException $e) { - if (str_contains($e->getMessage(), 'fswatch not exists')) { - $this->markTestSkipped(); - } - throw $e; - } finally { - if (isset($driver)) { - $driver->stop(); - } - if (isset($finished)) { - $this->assertTrue($finished->wait(0.1)); - } - $channel->close(); - } + parent::tearDown(); } - public function testStopTerminatesAndClosesProcess(): void + public function testConstructorProbesFswatchWithTheShellBuiltin(): void { - $option = new Option( - driver: FswatchDriver::class, - watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), - ], - scanInterval: 1, + $driver = new InspectableFswatchDriver($this->option()); + + $this->assertSame(['command -v fswatch'], $driver->executedCommands); + } + + public function testConstructorUsesTheProbeExitCode(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The FswatchDriver requires the `fswatch` executable.'); + + new InspectableFswatchDriver( + $this->option(), + probeResult: ['code' => 1, 'output' => '/stale/fswatch'], ); + } - // Stub that bypasses the `which fswatch` check and exposes a setter for the process handle. - $driver = new class($option) extends FswatchDriver { + public function testStopTerminatesProcessButLeavesResourceClosureToTheWatchOwner(): void + { + $driver = new class($this->option()) extends FswatchDriver { protected function exec(string $command): array { return ['code' => 0, 'output' => '/usr/bin/fswatch']; } - public function setProcess(mixed $process, array $pipes): void + public function setResources(mixed $process, array $pipes): void + { + $this->processes = ['shallow' => $process]; + $this->pipes = ['shallow' => $pipes[1]]; + } + + public function closeResources(): void { - $this->process = $process; - $this->pipes = $pipes; + $this->closeProcesses(); } }; - // Start a real child process. $process = proc_open(['sleep', '60'], [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], $pipes); $this->assertTrue(is_resource($process)); $pid = proc_get_status($process)['pid']; $this->assertTrue(posix_kill($pid, 0), 'Process should be running before stop()'); - $driver->setProcess($process, $pipes); + $driver->setResources($process, $pipes); $driver->stop(); $driver->stop(); - // After stop(), the process should be killed and the handle closed. - $this->assertFalse(is_resource($process), 'Process handle should be closed after stop()'); - $this->assertFalse(is_resource($pipes[0]), 'Process input pipe should be closed after stop()'); - $this->assertFalse(is_resource($pipes[1]), 'Process output pipe should be closed after stop()'); - $this->assertFalse(is_resource($pipes[2]), 'Process error pipe should be closed after stop()'); - $this->assertFalse(posix_kill($pid, 0), 'Process should not be running after stop()'); + $this->assertTrue(is_resource($process), 'The watch owner should retain the process handle until cleanup.'); + $this->assertTrue(is_resource($pipes[1]), 'The stopper must not close a pipe selected by the watch owner.'); + + fclose($pipes[0]); + fclose($pipes[2]); + $driver->closeResources(); + + $this->assertFalse(is_resource($process), 'The watch owner should close the process handle.'); + $this->assertFalse(is_resource($pipes[1]), 'The watch owner should close the output pipe.'); + $this->assertFalse(posix_kill($pid, 0), 'The direct child should not be running after cleanup.'); } public function testWatchFailsWhenTheFswatchProcessExits(): void @@ -112,14 +111,14 @@ protected function exec(string $command): array return ['code' => 0, 'output' => '/usr/bin/fswatch']; } - protected function getCommand(): array + protected function getCommand(array $operands = [], bool $recursive = false): array { return [PHP_BINARY, '-r', '']; } public function resourcesAreClosed(): bool { - return ! is_resource($this->process) && $this->pipes === []; + return $this->processes === [] && $this->pipes === []; } }; $channel = new Channel(1); @@ -177,14 +176,14 @@ protected function exec(string $command): array return ['code' => 0, 'output' => '/usr/bin/fswatch']; } - protected function getCommand(): array + protected function getCommand(array $operands = [], bool $recursive = false): array { return ['sleep', '60']; } public function resourcesAreClosed(): bool { - return ! is_resource($this->process) && $this->pipes === []; + return $this->processes === [] && $this->pipes === []; } }; $channel = new Channel(1); @@ -213,21 +212,23 @@ protected function exec(string $command): array return ['code' => 0, 'output' => '/usr/bin/fswatch']; } - protected function getCommand(): array + protected function getCommand(array $operands = [], bool $recursive = false): array { return ['sleep', '60']; } public function terminateProcess(): void { - if (is_resource($this->process)) { - proc_terminate($this->process, SIGKILL); + foreach ($this->processes as $process) { + if (is_resource($process)) { + proc_terminate($process, SIGKILL); + } } } public function resourcesAreClosed(): bool { - return ! is_resource($this->process) && $this->pipes === []; + return $this->processes === [] && $this->pipes === []; } }; $channel = new Channel(1); @@ -256,28 +257,32 @@ public function testCommandPreservesWatchPathsAsLiteralArguments(): void fn (string $path): WatchPath => new WatchPath($path, WatchPathType::Directory), $paths, ), - scanInterval: 1, ); - $driver = new class($option) extends FswatchDriver { - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/fswatch']; - } - - public function commandForTest(): array - { - return $this->getCommand(); - } - }; + $driver = new InspectableFswatchDriver($option); $resolvedPaths = array_map(base_path(...), $paths); $expected = $driver->isDarwin() - ? ['fswatch', ...$resolvedPaths] + ? [ + 'fswatch', + '-0', + '--format', + '%p', + '-r', + '--event', + 'Created', + '--event', + 'Updated', + '--event', + 'Removed', + '--event', + 'Renamed', + ...$resolvedPaths, + ] : [ 'fswatch', '-m', 'inotify_monitor', - '-E', + '-0', '--format', '%p', '-r', @@ -299,40 +304,560 @@ public function testTargetsNormalizeRootAndTrailingSlashes(): void { $watchPaths = [ new WatchPath('.', WatchPathType::Directory), - new WatchPath('/', WatchPathType::Directory), new WatchPath('app/', WatchPathType::Directory), ]; - $driver = new class(new Option(driver: FswatchDriver::class, watchPaths: $watchPaths)) extends FswatchDriver { - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/fswatch']; - } + $driver = new InspectableFswatchDriver(new Option(driver: FswatchDriver::class, watchPaths: $watchPaths)); - public function targetsForTest(array $watchPaths): array - { - return $this->resolveTargets($watchPaths); - } - }; + $this->assertSame( + [base_path(), base_path('app')], + $driver->operandsForTest(), + ); + } + + #[DataProvider('commandProvider')] + public function testCommandUsesTheSameNulProtocolAndEventFiltersOnEveryPlatform( + bool $darwin, + bool $recursive, + array $platformArguments, + ): void { + $watchPath = $recursive + ? new WatchPath($this->fixtureRelativePath, WatchPathType::Directory) + : new WatchPath( + $this->fixtureRelativePath, + WatchPathType::Directory, + $this->fixtureRelativePath . '/*.php', + ); + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: [$watchPath]), + darwin: $darwin, + ); + + $expected = [ + 'fswatch', + ...$platformArguments, + '-0', + '--format', + '%p', + ...($recursive ? ['-r'] : []), + '--event', + 'Created', + '--event', + 'Updated', + '--event', + 'Removed', + '--event', + 'Renamed', + $this->fixturePath, + ]; + + $this->assertSame($expected, $driver->commandForTest()); + } + + public static function commandProvider(): array + { + return [ + 'Linux shallow' => [false, false, ['-m', 'inotify_monitor']], + 'Linux recursive' => [false, true, ['-m', 'inotify_monitor']], + 'Darwin shallow' => [true, false, []], + 'Darwin recursive' => [true, true, []], + ]; + } + + public function testLinuxSeparatesShallowAndRecursiveOperands(): void + { + $this->files->makeDirectory($this->fixturePath . '/app'); + $this->files->put($this->fixturePath . '/.env', 'APP_ENV=local'); + $appBase = $this->fixtureRelativePath . '/app'; + $envPath = $this->fixtureRelativePath . '/.env'; + $watchPaths = [ + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/**/*.php'), + new WatchPath($envPath, WatchPathType::File), + ]; + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: $watchPaths), + darwin: false, + ); + + $this->assertSame([ + 'shallow' => [ + 'recursive' => false, + 'operands' => [$this->fixturePath], + ], + 'recursive' => [ + 'recursive' => true, + 'operands' => [$this->fixturePath . '/app'], + ], + ], $driver->targetsForTest()['groups']); + } + + public function testSharedOperandPromotesToRecursive(): void + { + $this->files->makeDirectory($this->fixturePath . '/app'); + $appBase = $this->fixtureRelativePath . '/app'; + $watchPaths = [ + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/*.php'), + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/**/*.js'), + ]; + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: $watchPaths), + darwin: false, + ); + + $this->assertSame([ + 'recursive' => [ + 'recursive' => true, + 'operands' => [$this->fixturePath . '/app'], + ], + ], $driver->targetsForTest()['groups']); + } + + public function testContainmentOnlyRemovesShallowOperandsFromTheRecursiveGroup(): void + { + $this->files->makeDirectory($this->fixturePath . '/app/Http', 0755, true); + $appBase = $this->fixtureRelativePath . '/app'; + $httpBase = $appBase . '/Http'; + $watchPaths = [ + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/**/*.php'), + new WatchPath($httpBase, WatchPathType::Directory, $httpBase . '/*.js'), + ]; + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: $watchPaths), + darwin: false, + ); + $targets = $driver->targetsForTest(); + $channel = new Channel(1); + $file = $this->fixturePath . '/app/Http/Controller.js'; + + try { + $driver->processChunksWithTargets($channel, [$file . "\0"], $targets); + + $this->assertSame([ + 'recursive' => [ + 'recursive' => true, + 'operands' => [$this->fixturePath . '/app'], + ], + ], $targets['groups']); + $this->assertSame(2, count($targets['entries'])); + $this->assertSame($file, $channel->pop()); + $this->assertSame(0, $channel->getLength()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testShallowSymlinkOutsideRecursiveTreeRetainsItsOperandAndPublishes(): void + { + $this->files->makeDirectory($this->fixturePath . '/app'); + $this->files->makeDirectory($this->fixturePath . '/outside'); + symlink($this->fixturePath . '/outside', $this->fixturePath . '/app/link'); + $appBase = $this->fixtureRelativePath . '/app'; + $linkBase = $appBase . '/link'; + $watchPaths = [ + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/**/*.php'), + new WatchPath($linkBase, WatchPathType::Directory, $linkBase . '/*.js'), + ]; + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: $watchPaths), + darwin: false, + ); + $targets = $driver->targetsForTest(); + $channel = new Channel(1); + $file = $this->fixturePath . '/outside/Foo.js'; + + try { + $driver->processChunksWithTargets($channel, [$file . "\0"], $targets); + + $this->assertSame([ + 'shallow' => [ + 'recursive' => false, + 'operands' => [$this->fixturePath . '/outside'], + ], + 'recursive' => [ + 'recursive' => true, + 'operands' => [$this->fixturePath . '/app'], + ], + ], $targets['groups']); + $this->assertSame($file, $channel->pop()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testShallowSymlinkInsideRecursiveTreeDropsItsOperandButKeepsItsMatcher(): void + { + $this->files->makeDirectory($this->fixturePath . '/app/real', 0755, true); + symlink($this->fixturePath . '/app/real', $this->fixturePath . '/app/link'); + $appBase = $this->fixtureRelativePath . '/app'; + $linkBase = $appBase . '/link'; + $watchPaths = [ + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/**/*.php'), + new WatchPath($linkBase, WatchPathType::Directory, $linkBase . '/*.js'), + ]; + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: $watchPaths), + darwin: false, + ); + $targets = $driver->targetsForTest(); + $channel = new Channel(1); + $file = $this->fixturePath . '/app/real/Foo.js'; + + try { + $driver->processChunksWithTargets($channel, [$file . "\0"], $targets); + + $this->assertSame([ + 'recursive' => [ + 'recursive' => true, + 'operands' => [$this->fixturePath . '/app'], + ], + ], $targets['groups']); + $this->assertSame(2, count($targets['entries'])); + $this->assertSame($file, $channel->pop()); + $this->assertSame(0, $channel->getLength()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testNestedOperandsWithinOneGroupAreRetained(): void + { + $this->files->makeDirectory($this->fixturePath . '/app/Http', 0755, true); + $appBase = $this->fixtureRelativePath . '/app'; + $httpBase = $appBase . '/Http'; + + $recursiveDriver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: [ + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/**/*.php'), + new WatchPath($httpBase, WatchPathType::Directory, $httpBase . '/**/*.js'), + ]), + darwin: false, + ); + $shallowDriver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: [ + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/*.php'), + new WatchPath($httpBase, WatchPathType::Directory, $httpBase . '/*.js'), + ]), + darwin: false, + ); + + $this->assertSame( + [$this->fixturePath . '/app', $this->fixturePath . '/app/Http'], + $recursiveDriver->targetsForTest()['groups']['recursive']['operands'], + ); + $this->assertSame( + [$this->fixturePath . '/app', $this->fixturePath . '/app/Http'], + $shallowDriver->targetsForTest()['groups']['shallow']['operands'], + ); + } + + public function testSiblingPathWithParentSegmentsRemainsASeparateOperand(): void + { + $siblingBase = '../packages/foo'; + $watchPaths = [ + new WatchPath('.', WatchPathType::Directory), + new WatchPath($siblingBase, WatchPathType::Directory, $siblingBase . '/*.php'), + ]; + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: $watchPaths), + darwin: false, + ); + $targets = $driver->targetsForTest(); + $channel = new Channel(1); + $file = base_path('../packages/foo/Package.php'); + + try { + $driver->processChunksWithTargets($channel, [$file . "\0"], $targets); + + $this->assertSame( + [base_path('../packages/foo')], + $targets['groups']['shallow']['operands'], + ); + $this->assertSame( + [realpath(base_path())], + $targets['groups']['recursive']['operands'], + ); + $this->assertSame($file, $channel->pop()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testStopBeforeWatchDoesNotOpenProcessResources(): void + { + $driver = new InspectableFswatchDriver($this->option()); + $channel = new Channel(1); + + try { + $driver->stop(); + $driver->watch($channel); + + $this->assertSame([], $driver->openedResourcesForTest()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testExactFilesShareTheirParentMappingAndMissingOperandsAreRetained(): void + { + $configPath = $this->fixturePath . '/config'; + $this->files->makeDirectory($configPath); + $missingRelativePath = $this->fixtureRelativePath . '/missing'; + $watchPaths = [ + new WatchPath($this->fixtureRelativePath . '/config/app.php', WatchPathType::File), + new WatchPath($this->fixtureRelativePath . '/config/queue.php', WatchPathType::File), + new WatchPath( + $missingRelativePath, + WatchPathType::Directory, + $missingRelativePath . '/*.php', + ), + ]; + $driver = new InspectableFswatchDriver(new Option(driver: FswatchDriver::class, watchPaths: $watchPaths)); $this->assertSame( - [base_path(), base_path(), base_path('app')], - $driver->targetsForTest($watchPaths), + [$configPath, $this->fixturePath . '/missing'], + $driver->operandsForTest(), + ); + $this->assertSame( + [ + [ + 'prefix' => $configPath . '/', + 'base' => $this->fixtureRelativePath . '/config', + ], + [ + 'prefix' => $this->fixturePath . '/missing/', + 'base' => $missingRelativePath, + ], + ], + $driver->targetsForTest()['entries'], + ); + } + + public function testMissingOperandKeepsItsLiteralPrefixThroughASymlinkedAncestor(): void + { + $this->files->makeDirectory($this->fixturePath . '/real'); + symlink($this->fixturePath . '/real', $this->fixturePath . '/link'); + $missingBase = $this->fixtureRelativePath . '/link/later'; + $watchPath = new WatchPath($missingBase, WatchPathType::Directory, $missingBase . '/*.php'); + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: [$watchPath]), + darwin: false, + ); + $targets = $driver->targetsForTest(); + $channel = new Channel(1); + $file = $this->fixturePath . '/link/later/Foo.php'; + + try { + $driver->processChunksWithTargets($channel, [$file . "\0"], $targets); + + $this->assertSame([$this->fixturePath . '/link/later'], $driver->operandsForTest()); + $this->assertSame([[ + 'prefix' => $this->fixturePath . '/link/later/', + 'base' => $missingBase, + ]], $targets['entries']); + $this->assertSame($file, $channel->pop()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testCanonicalEventsRecoverParentAndSymlinkConfiguredPaths(): void + { + $this->files->makeDirectory($this->fixturePath . '/app'); + $this->files->makeDirectory($this->fixturePath . '/sibling'); + $this->files->makeDirectory($this->fixturePath . '/real'); + symlink($this->fixturePath . '/real', $this->fixturePath . '/link'); + + $parentBase = $this->fixtureRelativePath . '/app/../sibling'; + $symlinkBase = $this->fixtureRelativePath . '/link'; + $combinedBase = $this->fixtureRelativePath . '/app/../link'; + $watchPaths = [ + new WatchPath($parentBase, WatchPathType::Directory, $parentBase . '/parent.php'), + new WatchPath($symlinkBase, WatchPathType::Directory, $symlinkBase . '/symlink.php'), + new WatchPath($combinedBase, WatchPathType::Directory, $combinedBase . '/combined.php'), + ]; + $driver = new InspectableFswatchDriver(new Option(driver: FswatchDriver::class, watchPaths: $watchPaths)); + $channel = new Channel(3); + $files = [ + $this->fixturePath . '/sibling/parent.php', + $this->fixturePath . '/real/symlink.php', + $this->fixturePath . '/real/combined.php', + ]; + + try { + $driver->processChunks($channel, [implode("\0", $files) . "\0"]); + + foreach ($files as $file) { + $this->assertSame($file, $channel->pop()); + } + + $this->assertSame(0, $channel->getLength()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testMissingShallowOperandThatBecomesOutsideSymlinkKeepsLiteralCoverage(): void + { + $this->files->makeDirectory($this->fixturePath . '/app'); + $this->files->makeDirectory($this->fixturePath . '/outside'); + $generatedBase = $this->fixtureRelativePath . '/app/Generated'; + $watchPaths = [ + new WatchPath( + $this->fixtureRelativePath . '/app', + WatchPathType::Directory, + $this->fixtureRelativePath . '/app/**/*.php', + ), + new WatchPath($generatedBase, WatchPathType::Directory, $generatedBase . '/*.js'), + ]; + $driver = new InspectableFswatchDriver(new Option(driver: FswatchDriver::class, watchPaths: $watchPaths)); + $targets = $driver->targetsForTest(); + $channel = new Channel(1); + $generatedFile = $this->fixturePath . '/app/Generated/Foo.js'; + + symlink($this->fixturePath . '/outside', dirname($generatedFile)); + + try { + $driver->processChunksWithTargets($channel, [$generatedFile . "\0"], $targets); + + $this->assertSame($generatedFile, $channel->pop()); + $this->assertSame([ + 'shallow' => [ + 'recursive' => false, + 'operands' => [$this->fixturePath . '/app/Generated'], + ], + 'recursive' => [ + 'recursive' => true, + 'operands' => [$this->fixturePath . '/app'], + ], + ], $targets['groups']); + $this->assertSame(2, count($targets['entries'])); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testAliasedMappingsPublishARecordOnce(): void + { + $this->files->makeDirectory($this->fixturePath . '/real'); + symlink($this->fixturePath . '/real', $this->fixturePath . '/link'); + $realBase = $this->fixtureRelativePath . '/real'; + $linkBase = $this->fixtureRelativePath . '/link'; + $watchPaths = [ + new WatchPath($realBase, WatchPathType::Directory, $realBase . '/*.php'), + new WatchPath($linkBase, WatchPathType::Directory, $linkBase . '/*.php'), + ]; + $driver = new InspectableFswatchDriver(new Option(driver: FswatchDriver::class, watchPaths: $watchPaths)); + $targets = $driver->targetsForTest(); + $channel = new Channel(2); + $file = $this->fixturePath . '/real/Foo.php'; + + try { + $driver->processChunksWithTargets($channel, [$file . "\0"], $targets); + + $this->assertSame(1, count($driver->operandsForTest())); + $this->assertSame(2, count($targets['entries'])); + $this->assertSame($file, $channel->pop()); + $this->assertSame(0, $channel->getLength()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testEventsOutsideEveryCanonicalTargetAreIgnored(): void + { + $watchPath = new WatchPath($this->fixtureRelativePath, WatchPathType::Directory); + $driver = new InspectableFswatchDriver( + new Option(driver: FswatchDriver::class, watchPaths: [$watchPath]), + ); + $channel = new Channel(1); + + try { + $driver->processChunks($channel, [base_path('outside.php') . "\0"]); + + $this->assertSame(0, $channel->getLength()); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testAtomicFileReplacementIsObservedThroughTheWatchedParent(): void + { + $relativePath = $this->fixtureRelativePath . '/watched.php'; + $path = base_path($relativePath); + $replacementPath = $this->fixturePath . '/replacement.php'; + $this->files->put($path, 'before'); + $option = new Option( + driver: FswatchDriver::class, + watchPaths: [new WatchPath($relativePath, WatchPathType::File)], ); + + try { + $driver = new FswatchDriver($option); + } catch (InvalidArgumentException $exception) { + if ($exception->getMessage() === 'The FswatchDriver requires the `fswatch` executable.') { + $this->markTestSkipped('The fswatch executable is not available.'); + } + + throw $exception; + } + + $channel = new Channel(20); + $finished = new WaitGroup(1); + $failure = null; + + Coroutine::create(function () use ($channel, $driver, $finished, &$failure): void { + try { + $driver->watch($channel); + } catch (RuntimeException $exception) { + $failure = $exception; + } finally { + $finished->done(); + } + }); + + try { + $deadline = hrtime(true) + 5_000_000_000; + $received = false; + + // Fswatch registers after startup and batches records on its default latency. + while (! $received && hrtime(true) < $deadline) { + $this->files->put($replacementPath, 'replacement'); + rename($replacementPath, $path); + $received = $channel->pop(0.25) === $path; + } + + $this->assertTrue($received, 'fswatch did not report an atomic file replacement.'); + } finally { + $driver->stop(); + $this->assertTrue($finished->wait(1)); + $channel->close(); + } + + $this->assertNull($failure); } - public function testWatchDeliversEachBatchInOrderWithoutDetachedChildren(): void + public function testWatchPassesTheCommandAsAnArgumentListAndDeliversEachBatchInOrder(): void { + $literalPath = 'shell $(literal).php'; $option = new Option( driver: FswatchDriver::class, watchPaths: [ - new WatchPath('first.php', WatchPathType::File), + new WatchPath($literalPath, WatchPathType::File), new WatchPath('second.php', WatchPathType::File), ], - scanInterval: 1, ); $driver = new OutputFswatchDriver( $option, - base_path('first.php') . "\n" . base_path('second.php') . "\n", + base_path($literalPath) . "\0" . base_path('second.php') . "\0", ); $channel = new Channel(2); @@ -345,55 +870,89 @@ public function testWatchDeliversEachBatchInOrderWithoutDetachedChildren(): void $driver->stop(); } - $this->assertSame(base_path('first.php'), $channel->pop()); + $this->assertSame(base_path($literalPath), $channel->pop()); $this->assertSame(base_path('second.php'), $channel->pop()); $this->assertSame(0, $channel->getLength()); + $this->assertSame(['shallow'], $driver->openedPipeGroups); $this->assertTrue($driver->resourcesAreClosed()); $channel->close(); } - public function testWatchPreservesPathsSplitAcrossReads(): void + public function testWatchReadsBothLinuxProcessGroupsAndOwnerCleanupReleasesThem(): void { + $this->files->makeDirectory($this->fixturePath . '/app'); + $this->files->put($this->fixturePath . '/.env', 'APP_ENV=local'); + $appBase = $this->fixtureRelativePath . '/app'; + $envPath = $this->fixtureRelativePath . '/.env'; $option = new Option( driver: FswatchDriver::class, watchPaths: [ - new WatchPath('first.php', WatchPathType::File), - new WatchPath('second.php', WatchPathType::File), - new WatchPath('third.php', WatchPathType::File), + new WatchPath($appBase, WatchPathType::Directory, $appBase . '/**/*.php'), + new WatchPath($envPath, WatchPathType::File), ], - scanInterval: 1, ); - $driver = new class($option) extends FswatchDriver { - protected function exec(string $command): array - { - return ['code' => 0, 'output' => '/usr/bin/fswatch']; + $files = [ + 'shallow' => $this->fixturePath . '/.env', + 'recursive' => $this->fixturePath . '/app/Model.php', + ]; + $driver = new GroupedOutputFswatchDriver($option, $files); + $channel = new Channel(2); + $finished = new WaitGroup(1); + $failure = null; + + Coroutine::create(function () use ($channel, $driver, $finished, &$failure): void { + try { + $driver->watch($channel); + } catch (RuntimeException $exception) { + $failure = $exception; + } finally { + $finished->done(); } + }); - public function processChunks(Channel $channel, array $chunks): void - { - $buffer = ''; - $watchPaths = $this->option->getWatchPaths(); + try { + $published = [$channel->pop(1), $channel->pop(1)]; + sort($published); + sort($files); - foreach ($chunks as $chunk) { - $this->processOutput($buffer, $chunk, $channel, base_path(), $watchPaths); - } + $this->assertSame(array_values($files), $published); + $this->assertSame(['recursive', 'shallow'], $driver->openedGroups()); + } finally { + $driver->stop(); + $this->assertTrue($finished->wait(1)); + $channel->close(); + } - $this->processOutput($buffer, '', $channel, base_path(), $watchPaths, final: true); - } - }; + $this->assertNull($failure); + $this->assertTrue($driver->resourcesAreClosed()); + } + + public function testNulParserPreservesFragmentsAndNewlinesAndIgnoresEmptyAndIncompleteRecords(): void + { + $newlinePath = "line\nbreak.php"; + $option = new Option( + driver: FswatchDriver::class, + watchPaths: [ + new WatchPath('first.php', WatchPathType::File), + new WatchPath('second.php', WatchPathType::File), + new WatchPath($newlinePath, WatchPathType::File), + new WatchPath('incomplete.php', WatchPathType::File), + ], + ); + $driver = new InspectableFswatchDriver($option); $channel = new Channel(3); $second = base_path('second.php'); try { $driver->processChunks($channel, [ - base_path('first.php') . "\n" . substr($second, 0, -3), - substr($second, -3) . "\n" . base_path('third.php'), + base_path('first.php') . "\0\0" . substr($second, 0, -3), + substr($second, -3) . "\0" . base_path($newlinePath) . "\0" . base_path('incomplete.php'), ]); $this->assertSame(base_path('first.php'), $channel->pop()); $this->assertSame(base_path('second.php'), $channel->pop()); - $this->assertSame(base_path('third.php'), $channel->pop()); + $this->assertSame(base_path($newlinePath), $channel->pop()); $this->assertSame(0, $channel->getLength()); } finally { $driver->stop(); @@ -407,11 +966,10 @@ public function testWatchPathFailureEscapesThroughTheOwnedDriverCoroutine(): voi $option = new Option( driver: FswatchDriver::class, watchPaths: [new ThrowingWatchPath($failure)], - scanInterval: 1, ); $driver = new OutputFswatchDriver( $option, - base_path('throw.php') . "\n", + base_path('throw.php') . "\0", ); $channel = new Channel(1); @@ -436,15 +994,142 @@ private function option(): Option return new Option( driver: FswatchDriver::class, watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), + new WatchPath($this->fixtureRelativePath, WatchPathType::Directory), ], - scanInterval: 1, ); } } +class InspectableFswatchDriver extends FswatchDriver +{ + /** @var list */ + public array $executedCommands = []; + + /** + * Create an inspectable fswatch driver. + * + * @param array{code: int, output: string} $probeResult + */ + public function __construct( + Option $option, + protected bool $darwin = false, + protected array $probeResult = ['code' => 0, 'output' => '/usr/bin/fswatch'], + ) { + parent::__construct($option); + } + + protected function exec(string $command): array + { + $this->executedCommands[] = $command; + + return $this->probeResult; + } + + public function isDarwin(): bool + { + return $this->darwin; + } + + /** + * Return the command for the configured paths. + * + * @return list + */ + public function commandForTest(): array + { + $targets = $this->targetsForTest(); + $group = array_values($targets['groups'])[0]; + + return $this->getCommand($group['operands'], $group['recursive']); + } + + /** + * Return commands for every configured process group. + * + * @return array> + */ + public function commandsForTest(): array + { + $commands = []; + + foreach ($this->targetsForTest()['groups'] as $name => $group) { + $commands[$name] = $this->getCommand($group['operands'], $group['recursive']); + } + + return $commands; + } + + /** + * Return every configured command operand. + * + * @return list + */ + public function operandsForTest(): array + { + return array_merge(...array_column($this->targetsForTest()['groups'], 'operands')); + } + + /** + * Return the names of groups holding process resources. + * + * @return list + */ + public function openedResourcesForTest(): array + { + return array_values(array_unique([ + ...array_keys($this->processes), + ...array_keys($this->pipes), + ])); + } + + /** + * Resolve the configured command operands and matcher mappings. + * + * @return array{ + * groups: array}>, + * entries: list + * } + */ + public function targetsForTest(): array + { + return $this->resolveWatchTargets($this->option->getWatchPaths()); + } + + /** + * Process scripted output chunks using the configured target mappings. + * + * @param list $chunks + */ + public function processChunks(Channel $channel, array $chunks): void + { + $this->processChunksWithTargets($channel, $chunks, $this->targetsForTest()); + } + + /** + * Process scripted output chunks using supplied target mappings. + * + * @param list $chunks + * @param array{ + * groups: array}>, + * entries: list + * } $targets + */ + public function processChunksWithTargets(Channel $channel, array $chunks, array $targets): void + { + $buffer = ''; + $watchPaths = $this->option->getWatchPaths(); + + foreach ($chunks as $chunk) { + $this->processOutput($buffer, $chunk, $channel, $watchPaths, $targets['entries']); + } + } +} + class OutputFswatchDriver extends FswatchDriver { + /** @var list */ + public array $openedPipeGroups = []; + public function __construct( Option $option, protected string $output, @@ -457,18 +1142,80 @@ protected function exec(string $command): array return ['code' => 0, 'output' => '/usr/bin/fswatch']; } - protected function getCommand(): array + protected function getCommand(array $operands = [], bool $recursive = false): array + { + return [ + PHP_BINARY, + '-r', + 'fwrite(STDOUT, base64_decode($argv[1], true));', + base64_encode($this->output), + ]; + } + + protected function openProcess(string $group, array $operands, bool $recursive): void + { + parent::openProcess($group, $operands, $recursive); + + $this->openedPipeGroups = array_keys($this->pipes); + } + + public function resourcesAreClosed(): bool + { + return $this->processes === [] && $this->pipes === []; + } +} + +class GroupedOutputFswatchDriver extends FswatchDriver +{ + /** + * Create a driver with one scripted record per process group. + * + * @param array{shallow: string, recursive: string} $outputs + */ + public function __construct( + Option $option, + protected array $outputs, + ) { + parent::__construct($option); + } + + protected function exec(string $command): array + { + return ['code' => 0, 'output' => '/usr/bin/fswatch']; + } + + protected function getCommand(array $operands = [], bool $recursive = false): array { + $group = $recursive ? 'recursive' : 'shallow'; + return [ PHP_BINARY, '-r', - 'fwrite(STDOUT, ' . var_export($this->output, true) . ');', + 'usleep((int) $argv[1]); fwrite(STDOUT, base64_decode($argv[2], true)); usleep(500000);', + $recursive ? '20000' : '10000', + base64_encode($this->outputs[$group] . "\0"), ]; } + /** + * Return the opened process groups. + * + * @return list + */ + public function openedGroups(): array + { + $groups = array_keys($this->processes); + sort($groups); + + return $groups; + } + + /** + * Determine whether every process resource was released. + */ public function resourcesAreClosed(): bool { - return ! is_resource($this->process) && $this->pipes === []; + return $this->processes === [] && $this->pipes === []; } } @@ -476,7 +1223,7 @@ public function resourcesAreClosed(): bool { public function __construct(public RuntimeException $failure) { - parent::__construct('', WatchPathType::Directory); + parent::__construct('.', WatchPathType::Directory); } public function matches(string $relativePath): bool @@ -491,6 +1238,12 @@ class FswatchDriverStreamState public int $closeCount = 0; + /** @var null|resource */ + public mixed $selectStream = null; + + /** @var null|resource */ + public mixed $selectPeer = null; + /** * Create a scripted stream state. */ @@ -544,12 +1297,30 @@ public function stream_eof(): bool return $this->state->eof; } + /** + * Return the selectable stream behind the scripted wrapper. + * + * @return resource + */ + public function stream_cast(int $castAs): mixed + { + return $this->state->selectStream; + } + /** * Record stream closure. */ public function stream_close(): void { ++$this->state->closeCount; + + foreach (['selectStream', 'selectPeer'] as $property) { + if (is_resource($this->state->{$property})) { + fclose($this->state->{$property}); + } + + $this->state->{$property} = null; + } } } @@ -578,7 +1349,7 @@ protected function exec(string $command): array /** * Open a live child with a scripted output stream. */ - protected function openProcess(): void + protected function openProcess(string $group, array $operands, bool $recursive): void { $process = proc_open(['sleep', '60'], [['pipe', 'r'], ['pipe', 'w']], $pipes); @@ -596,6 +1367,12 @@ protected function openProcess(): void } $context = stream_context_create([$protocol => ['state' => $this->state]]); + [$this->state->selectStream, $this->state->selectPeer] = stream_socket_pair( + STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + STREAM_IPPROTO_IP, + ); + fwrite($this->state->selectPeer, 'x'); $pipe = fopen($protocol . '://stream', 'r', false, $context); if (! is_resource($pipe)) { @@ -605,8 +1382,8 @@ protected function openProcess(): void throw new RuntimeException('Unable to open the test output stream.'); } - $this->process = $process; - $this->pipes = [1 => $pipe]; + $this->processes = [$group => $process]; + $this->pipes = [$group => $pipe]; } /** @@ -627,6 +1404,6 @@ public function stop(): void */ public function resourcesAreClosed(): bool { - return ! is_resource($this->process) && $this->pipes === []; + return $this->processes === [] && $this->pipes === []; } } diff --git a/tests/Watcher/Driver/ScanFileDriverTest.php b/tests/Watcher/Driver/ScanFileDriverTest.php index 8deee4f85f..a845b39ddc 100644 --- a/tests/Watcher/Driver/ScanFileDriverTest.php +++ b/tests/Watcher/Driver/ScanFileDriverTest.php @@ -4,11 +4,12 @@ namespace Hypervel\Tests\Watcher\Driver; +use Hypervel\Contracts\Log\StdoutLoggerInterface; use Hypervel\Coroutine\WaitGroup; use Hypervel\Engine\Channel; use Hypervel\Engine\Coroutine; use Hypervel\Filesystem\Filesystem; -use Hypervel\Tests\TestCase; +use Hypervel\Testbench\TestCase; use Hypervel\Tests\Watcher\Fixtures\ContainerStub; use Hypervel\Tests\Watcher\Fixtures\ScanFileDriverStub; use Hypervel\Watcher\Driver\ScanFileDriver; @@ -16,22 +17,36 @@ use Hypervel\Watcher\WatchPath; use Hypervel\Watcher\WatchPathType; use Mockery as m; -use Symfony\Component\Finder\Exception\DirectoryNotFoundException; class ScanFileDriverTest extends TestCase { - public function testWatch(): void - { - $option = new Option( - driver: ScanFileDriver::class, - watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), - new WatchPath('.env', WatchPathType::File), - ], - scanInterval: 1, - ); + protected string $fixturePath; + + protected string $fixtureRelativePath = 'watcher-scan-driver-test'; + + protected Filesystem $filesystem; + + protected function setUp(): void + { + parent::setUp(); - $channel = new Channel(10); + $this->filesystem = new Filesystem; + $this->fixturePath = base_path($this->fixtureRelativePath); + $this->filesystem->deleteDirectory($this->fixturePath); + $this->filesystem->ensureDirectoryExists($this->fixturePath); + } + + protected function tearDown(): void + { + $this->filesystem->deleteDirectory($this->fixturePath); + + parent::tearDown(); + } + + public function testWatchEstablishesAnImmediateBaselineAndReportsTheNextSnapshot(): void + { + $option = new Option(driver: ScanFileDriver::class, scanInterval: 1); + $channel = new Channel(1); $driver = new ScanFileDriverStub($option, ContainerStub::getLogger()); $finished = new WaitGroup(1); @@ -44,7 +59,7 @@ public function testWatch(): void }); try { - $this->assertStringEndsWith('.env', $channel->pop(($option->getScanIntervalSeconds() * 2) + 0.1)); + $this->assertSame('.env', $channel->pop(0.1)); } finally { $driver->stop(); $this->assertTrue($finished->wait(0.1)); @@ -52,188 +67,395 @@ public function testWatch(): void } } - public function testAddAndModifyInSameCycleReportsBothCorrectly(): void + public function testAddedModifiedAndDeletedFilesAreReportedIndependently(): void { - $option = new Option( - driver: ScanFileDriver::class, - watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), - ], - scanInterval: 1, - ); + $driver = new ScanFileDriverTestProxy(new Option, ContainerStub::getLogger()); + $channel = new Channel(3); - $logger = ContainerStub::getLogger(); + try { + $driver->processForTest($channel, [ + '/tmp/unchanged.php' => 'same', + '/tmp/modified.php' => 'old', + '/tmp/deleted.php' => 'deleted', + ]); + $driver->processForTest($channel, [ + '/tmp/unchanged.php' => 'same', + '/tmp/modified.php' => 'new', + '/tmp/added.php' => 'added', + ]); - // Anonymous stub that returns different file hash maps on successive calls. - // Tick 1: {A, C} — establishes baseline. - // Tick 2: {A, B, C_changed} — B is added, C is modified, A is unchanged. - $driver = new class($option, $logger) extends ScanFileDriver { - private int $callCount = 0; + $this->assertSame('/tmp/added.php', $channel->pop(0.1)); + $this->assertSame('/tmp/deleted.php', $channel->pop(0.1)); + $this->assertSame('/tmp/modified.php', $channel->pop(0.1)); + } finally { + $driver->stop(); + $channel->close(); + } + } - protected function getWatchFileHashes(): array - { - return match (++$this->callCount) { - 1 => ['/tmp/A.php' => 'hash_a', '/tmp/C.php' => 'hash_c'], - default => ['/tmp/A.php' => 'hash_a', '/tmp/B.php' => 'hash_b', '/tmp/C.php' => 'hash_c_changed'], - }; - } - }; + public function testRenamedFileReportsItsOldAndNewPaths(): void + { + $this->putFixture('rename/old.php', 'contents'); + $oldPath = $this->fixturePath . '/rename/old.php'; + $newPath = $this->fixturePath . '/rename/new.php'; + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($this->fixtureRelativePath . '/rename', WatchPathType::Directory), + ]), ContainerStub::getLogger()); + $channel = new Channel(2); - $channel = new Channel(10); - $finished = new WaitGroup(1); - Coroutine::create(function () use ($channel, $driver, $finished): void { - try { - $driver->watch($channel); - } finally { - $finished->done(); - } - }); + try { + $driver->processForTest($channel, $driver->fileHashesForTest()); + rename($oldPath, $newPath); + $driver->processForTest($channel, $driver->fileHashesForTest()); + + $this->assertEqualsCanonicalizing( + [$oldPath, $newPath], + [$channel->pop(0.1), $channel->pop(0.1)], + ); + $this->assertFalse($channel->pop(0.01)); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testSnapshotOrderingDoesNotCreateFalseChanges(): void + { + $driver = new ScanFileDriverTestProxy(new Option, ContainerStub::getLogger()); + $channel = new Channel(1); try { - // Wait for two ticks to fire (baseline + detection). - $pushed = []; - $timeout = 0.2; - while (($file = $channel->pop($timeout)) !== false) { - $pushed[] = $file; - $timeout = 0.05; - } + $driver->processForTest($channel, [ + '/tmp/a.php' => 'a', + '/tmp/b.php' => 'b', + ]); + $driver->processForTest($channel, [ + '/tmp/b.php' => 'b', + '/tmp/a.php' => 'a', + ]); - // B should be reported as added, C as modified. A should NOT appear. - $this->assertContains('/tmp/B.php', $pushed); - $this->assertContains('/tmp/C.php', $pushed); - $this->assertNotContains('/tmp/A.php', $pushed); - $this->assertCount(2, $pushed); + $this->assertFalse($channel->pop(0.01)); + } finally { + $driver->stop(); + $channel->close(); + } + } + + public function testLogsOnlyWhenAProcessedSnapshotContainsChanges(): void + { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('debug') + ->once() + ->with(ScanFileDriver::class . ' Watching: Total:2, Change:1, Add:1, Delete:1.'); + $driver = new ScanFileDriverTestProxy(new Option, $logger); + $channel = new Channel(3); + + try { + $driver->processForTest($channel, ['/tmp/a.php' => 'old', '/tmp/deleted.php' => 'old']); + $driver->processForTest($channel, ['/tmp/a.php' => 'old', '/tmp/deleted.php' => 'old']); + $driver->processForTest($channel, ['/tmp/a.php' => 'new', '/tmp/added.php' => 'new']); } finally { $driver->stop(); - $this->assertTrue($finished->wait(0.1)); $channel->close(); } } - public function testEmptyBaselineReportsNewFiles(): void + public function testStreamsRecursiveHiddenFilesAndPreservesVcsExclusion(): void + { + $this->putFixture('.hidden.php', 'root hidden'); + $this->putFixture('.hidden/nested.php', 'nested hidden'); + $this->putFixture('visible/nested.php', 'visible'); + $this->putFixture('.git/ignored.php', 'ignored'); + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath( + $this->fixtureRelativePath, + WatchPathType::Directory, + $this->fixtureRelativePath . '/**/*.php', + ), + ]), ContainerStub::getLogger()); + + $hashes = $driver->fileHashesForTest(); + + $this->assertArrayHasKey($this->fixturePath . '/.hidden.php', $hashes); + $this->assertArrayHasKey($this->fixturePath . '/.hidden/nested.php', $hashes); + $this->assertArrayHasKey($this->fixturePath . '/visible/nested.php', $hashes); + $this->assertArrayNotHasKey($this->fixturePath . '/.git/ignored.php', $hashes); + } + + public function testFollowsASymlinkOperandButNotSymlinksFoundDuringDescent(): void + { + $this->putFixture('real/direct.php', 'direct'); + $this->putFixture('outside/nested.php', 'nested'); + symlink($this->fixturePath . '/outside', $this->fixturePath . '/real/nested-link'); + symlink($this->fixturePath . '/real', $this->fixturePath . '/root-link'); + $linkBase = $this->fixtureRelativePath . '/root-link'; + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($linkBase, WatchPathType::Directory), + ]), ContainerStub::getLogger()); + + $hashes = $driver->fileHashesForTest(); + + $this->assertArrayHasKey($this->fixturePath . '/root-link/direct.php', $hashes); + $this->assertArrayNotHasKey($this->fixturePath . '/root-link/nested-link/nested.php', $hashes); + } + + public function testPreservesParentSegmentsWhenMatchingASiblingPath(): void + { + $this->filesystem->makeDirectory($this->fixturePath . '/app'); + $this->putFixture('sibling/File.php', 'sibling'); + $siblingBase = $this->fixtureRelativePath . '/app/../sibling'; + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($siblingBase, WatchPathType::Directory, $siblingBase . '/*.php'), + ]), ContainerStub::getLogger()); + $path = $this->fixturePath . '/app/../sibling/File.php'; + + $this->assertSame([$path => hash_file('xxh128', $path)], $driver->fileHashesForTest()); + } + + public function testShallowGlobDoesNotTraverseNestedTrees(): void { - $option = new Option( - driver: ScanFileDriver::class, - watchPaths: [ - new WatchPath('/tmp', WatchPathType::Directory), - ], - scanInterval: 1, + $this->putFixture('root.php', 'root'); + $this->putFixture('vendor/package.php', 'vendor'); + $this->putFixture('node_modules/package.php', 'node'); + $this->putFixture('storage/cache.php', 'storage'); + $filesystem = new CountingFilesystem; + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath( + $this->fixtureRelativePath, + WatchPathType::Directory, + $this->fixtureRelativePath . '/*.php', + ), + ]), ContainerStub::getLogger(), $filesystem); + + $hashes = $driver->fileHashesForTest(); + + $this->assertSame([$this->fixturePath . '/root.php'], array_keys($hashes)); + $this->assertSame([$this->fixturePath . '/root.php'], $filesystem->hashedPaths); + } + + public function testIdenticalTargetsWalkOnceAndRecursiveTraversalWins(): void + { + $this->putFixture('root.php', 'root'); + $this->putFixture('nested/file.php', 'nested'); + $filesystem = new CountingFilesystem; + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath( + $this->fixtureRelativePath, + WatchPathType::Directory, + $this->fixtureRelativePath . '/*.php', + ), + new WatchPath( + $this->fixtureRelativePath, + WatchPathType::Directory, + $this->fixtureRelativePath . '/**/*.php', + ), + ]), ContainerStub::getLogger(), $filesystem); + + $hashes = $driver->fileHashesForTest(); + + $this->assertCount(2, $hashes); + $this->assertEqualsCanonicalizing( + [$this->fixturePath . '/root.php', $this->fixturePath . '/nested/file.php'], + $filesystem->hashedPaths, ); + $this->assertCount(2, $filesystem->hashedPaths); + } + + public function testOverlappingDifferentRootsAndExplicitFilesHashEachMatchedPathOnce(): void + { + $this->putFixture('nested/file.php', 'nested'); + $path = $this->fixturePath . '/nested/file.php'; + $filesystem = new CountingFilesystem; + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($this->fixtureRelativePath, WatchPathType::Directory), + new WatchPath($this->fixtureRelativePath . '/nested', WatchPathType::Directory), + new WatchPath($this->fixtureRelativePath . '/nested/file.php', WatchPathType::File), + ]), ContainerStub::getLogger(), $filesystem); + + $this->assertSame([$path => hash_file('xxh128', $path)], $driver->fileHashesForTest()); + $this->assertSame([$path], $filesystem->hashedPaths); + } - $logger = ContainerStub::getLogger(); + public function testMissingTargetsContributeNoEntriesAndDoNotHideLaterRoots(): void + { + $this->putFixture('present/file.php', 'present'); + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($this->fixtureRelativePath . '/missing', WatchPathType::Directory), + new WatchPath($this->fixtureRelativePath . '/present', WatchPathType::Directory), + new WatchPath($this->fixtureRelativePath . '/missing.php', WatchPathType::File), + ]), ContainerStub::getLogger()); + + $this->assertSame( + [$this->fixturePath . '/present/file.php'], + array_keys($driver->fileHashesForTest()), + ); + } - $driver = new class($option, $logger) extends ScanFileDriver { - public function process(Channel $channel, array $fileHashes): void - { - $this->processFileHashes($channel, $fileHashes); + public function testUnreadableSubtreeDoesNotHideReadableSiblings(): void + { + $this->putFixture('readable/file.php', 'readable'); + $this->putFixture('unreadable/file.php', 'unreadable'); + $unreadablePath = $this->fixturePath . '/unreadable'; + chmod($unreadablePath, 0000); + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($this->fixtureRelativePath, WatchPathType::Directory), + ]), ContainerStub::getLogger()); + + try { + $hashes = $driver->fileHashesForTest(); + + $this->assertArrayHasKey($this->fixturePath . '/readable/file.php', $hashes); + + if (! is_readable($unreadablePath)) { + $this->assertArrayNotHasKey($unreadablePath . '/file.php', $hashes); } - }; + } finally { + chmod($unreadablePath, 0777); + } + } - $channel = new Channel(10); + public function testUnreadableWatchedRootDoesNotHideOtherRootsAndRecoversAsDeleteAdd(): void + { + $this->putFixture('readable/file.php', 'readable'); + $this->putFixture('unreadable/file.php', 'unreadable'); + $readableFile = $this->fixturePath . '/readable/file.php'; + $unreadableFile = $this->fixturePath . '/unreadable/file.php'; + $unreadablePath = $this->fixturePath . '/unreadable'; + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($this->fixtureRelativePath . '/readable', WatchPathType::Directory), + new WatchPath($this->fixtureRelativePath . '/unreadable', WatchPathType::Directory), + ]), ContainerStub::getLogger()); + $channel = new Channel(2); try { - $driver->process($channel, []); - $driver->process($channel, ['/tmp/B.php' => 'hash_b']); + $driver->processForTest($channel, $driver->fileHashesForTest()); + chmod($unreadablePath, 0000); + clearstatcache(true, $unreadablePath); - $this->assertSame('/tmp/B.php', $channel->pop(0.1)); + if (is_readable($unreadablePath)) { + $this->markTestSkipped('The current user can still read directories with mode 0000.'); + } + + $unreadableSnapshot = $driver->fileHashesForTest(); + + $this->assertArrayHasKey($readableFile, $unreadableSnapshot); + $this->assertArrayNotHasKey($unreadableFile, $unreadableSnapshot); + + $driver->processForTest($channel, $unreadableSnapshot); + $this->assertSame($unreadableFile, $channel->pop(0.1)); + + chmod($unreadablePath, 0777); + clearstatcache(true, $unreadablePath); + $recoveredSnapshot = $driver->fileHashesForTest(); + + $this->assertArrayHasKey($readableFile, $recoveredSnapshot); + $this->assertArrayHasKey($unreadableFile, $recoveredSnapshot); + + $driver->processForTest($channel, $recoveredSnapshot); + $this->assertSame($unreadableFile, $channel->pop(0.1)); $this->assertFalse($channel->pop(0.01)); } finally { + chmod($unreadablePath, 0777); $driver->stop(); $channel->close(); } } - public function testUnreadableFileHashesReturnNull(): void + public function testHashFailureOmitsTheFileFromTheSnapshot(): void { - $option = new Option( - driver: ScanFileDriver::class, - watchPaths: [ - new WatchPath('/tmp/unreadable.php', WatchPathType::File), - ], - scanInterval: 1, - ); - - $logger = ContainerStub::getLogger(); + $this->putFixture('unreadable.php', 'contents'); + $path = $this->fixturePath . '/unreadable.php'; $filesystem = m::mock(Filesystem::class); - $filesystem->shouldReceive('hash') - ->once() - ->with('/tmp/unreadable.php') - ->andReturn(false); + $filesystem->shouldReceive('hash')->once()->with($path)->andReturn(false); + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($this->fixtureRelativePath . '/unreadable.php', WatchPathType::File), + ]), ContainerStub::getLogger(), $filesystem); - $driver = new class($option, $logger, $filesystem) extends ScanFileDriver { - public function hashPath(string $path): ?string - { - return $this->hashFile($path); - } - }; + $this->assertSame([], $driver->fileHashesForTest()); + } - $this->assertNull($driver->hashPath('/tmp/unreadable.php')); + public function testSameSizeRewriteWithRestoredMtimeIsDetectedByContentHash(): void + { + $this->putFixture('same-size.php', 'first'); + $path = $this->fixturePath . '/same-size.php'; + $modifiedAt = filemtime($path); + $this->assertIsInt($modifiedAt); + $driver = new ScanFileDriverTestProxy(new Option(watchPaths: [ + new WatchPath($this->fixtureRelativePath . '/same-size.php', WatchPathType::File), + ]), ContainerStub::getLogger()); + $channel = new Channel(1); - $driver->stop(); + try { + $driver->processForTest($channel, $driver->fileHashesForTest()); + file_put_contents($path, 'other'); + touch($path, $modifiedAt); + clearstatcache(true, $path); + $driver->processForTest($channel, $driver->fileHashesForTest()); + + $this->assertSame($path, $channel->pop(0.1)); + } finally { + $driver->stop(); + $channel->close(); + } } - public function testAddedModifiedAndDeletedFilesAreReportedIndependently(): void + public function testLargeOrderIndependentSnapshotsRemainExact(): void { - $driver = new class(new Option(driver: ScanFileDriver::class), ContainerStub::getLogger()) extends ScanFileDriver { - public function process(Channel $channel, array $fileHashes): void - { - $this->processFileHashes($channel, $fileHashes); - } - }; - $channel = new Channel(3); + $driver = new ScanFileDriverTestProxy(new Option, ContainerStub::getLogger()); + $channel = new Channel(2); + $baseline = []; + + for ($index = 0; $index < 5000; ++$index) { + $baseline["/tmp/{$index}.php"] = (string) $index; + } + + $changed = array_reverse($baseline, preserve_keys: true); + $changed['/tmp/2500.php'] = 'changed'; + $changed['/tmp/added.php'] = 'added'; try { - $driver->process($channel, [ - '/tmp/unchanged.php' => 'same', - '/tmp/modified.php' => 'old', - '/tmp/deleted.php' => 'deleted', - ]); - $driver->process($channel, [ - '/tmp/unchanged.php' => 'same', - '/tmp/modified.php' => 'new', - '/tmp/added.php' => 'added', - ]); + $driver->processForTest($channel, $baseline); + $driver->processForTest($channel, $changed); $this->assertSame('/tmp/added.php', $channel->pop(0.1)); - $this->assertSame('/tmp/deleted.php', $channel->pop(0.1)); - $this->assertSame('/tmp/modified.php', $channel->pop(0.1)); + $this->assertSame('/tmp/2500.php', $channel->pop(0.1)); + $this->assertFalse($channel->pop(0.01)); } finally { $driver->stop(); $channel->close(); } } - public function testMissingDirectoryDoesNotPreventLaterRootsFromBeingScanned(): void + private function putFixture(string $relativePath, string $contents): void { - $filesystem = m::mock(Filesystem::class); - $filesystem->shouldReceive('allFiles') - ->once() - ->with('/tmp/missing') - ->andThrow(new DirectoryNotFoundException('/tmp/missing')); - $filesystem->shouldReceive('allFiles') - ->once() - ->with('/tmp/present') - ->andReturn([]); - - $option = new Option( - driver: ScanFileDriver::class, - watchPaths: [ - new WatchPath('missing', WatchPathType::Directory), - new WatchPath('present', WatchPathType::Directory), - ], - ); - $driver = new class($option, ContainerStub::getLogger(), $filesystem) extends ScanFileDriver { - protected function resolveTargets(array $watchPaths): array - { - return ['/tmp/missing', '/tmp/present']; - } + $path = $this->fixturePath . '/' . $relativePath; + $this->filesystem->ensureDirectoryExists(dirname($path)); + file_put_contents($path, $contents); + } +} - public function fileHashesForTest(): array - { - return $this->getWatchFileHashes(); - } - }; +class ScanFileDriverTestProxy extends ScanFileDriver +{ + public function processForTest(Channel $channel, array $fileHashes): void + { + $this->processFileHashes($channel, $fileHashes); + } - $this->assertSame([], $driver->fileHashesForTest()); + public function fileHashesForTest(): array + { + return $this->getWatchFileHashes(); + } +} + +class CountingFilesystem extends Filesystem +{ + /** @var list */ + public array $hashedPaths = []; + + public function hash(string $path, string $algorithm = 'xxh128'): string|false + { + $this->hashedPaths[] = $path; + + return parent::hash($path, $algorithm); } } diff --git a/tests/Watcher/Fixtures/FindDriverStub.php b/tests/Watcher/Fixtures/FindDriverStub.php index 2f7e8ed468..b36905b6af 100644 --- a/tests/Watcher/Fixtures/FindDriverStub.php +++ b/tests/Watcher/Fixtures/FindDriverStub.php @@ -8,8 +8,18 @@ class FindDriverStub extends FindDriver { - protected function scan(array $fileModifyTimes, string $minutes): array + protected function scan(): array { - return [[], ['.env']]; + return [ + 'files' => ['.env'], + 'changedComplete' => true, + 'inventoryComplete' => true, + 'failureCode' => null, + ]; + } + + public function referenceFilesForTest(): array + { + return $this->referenceFiles; } } diff --git a/tests/Watcher/Fixtures/FindNewerDriverStub.php b/tests/Watcher/Fixtures/FindNewerDriverStub.php deleted file mode 100644 index db42522c9a..0000000000 --- a/tests/Watcher/Fixtures/FindNewerDriverStub.php +++ /dev/null @@ -1,28 +0,0 @@ -scan(); - - foreach ($changedFiles as $file) { - $channel->push($file); - } - - $this->watchAtInterval(60, static function (): void { - }); - } - - protected function scan(): array - { - return [['.env'], null]; - } -} diff --git a/tests/Watcher/Fixtures/FswatchDriverStub.php b/tests/Watcher/Fixtures/FswatchDriverStub.php deleted file mode 100644 index a9138168ce..0000000000 --- a/tests/Watcher/Fixtures/FswatchDriverStub.php +++ /dev/null @@ -1,18 +0,0 @@ -push('.env'); - $this->watchAtInterval(60, static function (): void { - }); - } -} diff --git a/tests/Watcher/OptionTest.php b/tests/Watcher/OptionTest.php index 1089a9fe2d..b6bcdace2b 100644 --- a/tests/Watcher/OptionTest.php +++ b/tests/Watcher/OptionTest.php @@ -51,6 +51,7 @@ public function testFromConfigParsesBareDirectory(): void $this->assertSame('app', $paths[0]->path); $this->assertSame(WatchPathType::Directory, $paths[0]->type); $this->assertNull($paths[0]->pattern); + $this->assertTrue($paths[0]->recursive); } public function testFromConfigParsesGlobWithExtension(): void @@ -62,6 +63,7 @@ public function testFromConfigParsesGlobWithExtension(): void $this->assertSame('config', $paths[0]->path); $this->assertSame(WatchPathType::Directory, $paths[0]->type); $this->assertSame('config/**/*.php', $paths[0]->pattern); + $this->assertTrue($paths[0]->recursive); } public function testFromConfigParsesCompoundExtensionGlob(): void @@ -73,6 +75,7 @@ public function testFromConfigParsesCompoundExtensionGlob(): void $this->assertSame('resources', $paths[0]->path); $this->assertSame(WatchPathType::Directory, $paths[0]->type); $this->assertSame('resources/**/*.blade.php', $paths[0]->pattern); + $this->assertTrue($paths[0]->recursive); } public function testFromConfigParsesMiddleWildcard(): void @@ -84,6 +87,7 @@ public function testFromConfigParsesMiddleWildcard(): void $this->assertSame('app', $paths[0]->path); $this->assertSame(WatchPathType::Directory, $paths[0]->type); $this->assertSame('app/*/Actions/*.php', $paths[0]->pattern); + $this->assertTrue($paths[0]->recursive); } public function testFromConfigParsesQuestionMarkGlob(): void @@ -95,6 +99,7 @@ public function testFromConfigParsesQuestionMarkGlob(): void $this->assertSame('routes', $paths[0]->path); $this->assertSame(WatchPathType::Directory, $paths[0]->type); $this->assertSame('routes/?.php', $paths[0]->pattern); + $this->assertFalse($paths[0]->recursive); } public function testFromConfigParsesBraceGlob(): void @@ -106,6 +111,7 @@ public function testFromConfigParsesBraceGlob(): void $this->assertSame('config', $paths[0]->path); $this->assertSame(WatchPathType::Directory, $paths[0]->type); $this->assertSame('config/{app,queue}.php', $paths[0]->pattern); + $this->assertFalse($paths[0]->recursive); } public function testFromConfigParsesBracketGlob(): void @@ -117,6 +123,7 @@ public function testFromConfigParsesBracketGlob(): void $this->assertSame('lang', $paths[0]->path); $this->assertSame(WatchPathType::Directory, $paths[0]->type); $this->assertSame('lang/[a-z][a-z].php', $paths[0]->pattern); + $this->assertFalse($paths[0]->recursive); } public function testFromConfigParsesSpecificFile(): void @@ -128,6 +135,7 @@ public function testFromConfigParsesSpecificFile(): void $this->assertSame('.env', $paths[0]->path); $this->assertSame(WatchPathType::File, $paths[0]->type); $this->assertNull($paths[0]->pattern); + $this->assertFalse($paths[0]->recursive); } public function testFromConfigParsesDotlessFile(): void @@ -172,23 +180,111 @@ public function testFromConfigDeduplicatesPaths(): void $this->assertSame('.env', $paths[1]->path); } + public function testFromConfigNormalizesPathsBeforeParsingAndDeduplicating(): void + { + $option = Option::fromConfig([ + 'watch' => [ + 'app/', + './app', + 'app/./', + 'app//', + '.', + './', + './/', + '../packages/foo/', + 'app/../outside/', + 'app//*.php', + './app/*.php', + 'app/./*.php', + ], + ], $this->tempDir); + + $paths = $option->getWatchPaths(); + + $this->assertCount(5, $paths); + $this->assertSame('app', $paths[0]->path); + $this->assertSame('.', $paths[1]->path); + $this->assertSame('../packages/foo', $paths[2]->path); + $this->assertSame('app/../outside', $paths[3]->path); + $this->assertSame('app', $paths[4]->path); + $this->assertSame('app/*.php', $paths[4]->pattern); + } + + public function testFromConfigUsesDirectoryBeforeWildcardAsGlobBase(): void + { + $option = Option::fromConfig([ + 'watch' => ['.env*', 'app/Foo*.php'], + ], $this->tempDir); + + $paths = $option->getWatchPaths(); + + $this->assertSame('.', $paths[0]->path); + $this->assertSame('.env*', $paths[0]->pattern); + $this->assertFalse($paths[0]->recursive); + $this->assertSame('app', $paths[1]->path); + $this->assertSame('app/Foo*.php', $paths[1]->pattern); + $this->assertFalse($paths[1]->recursive); + } + + public function testFromConfigRejectsAnEmptyWatchEntry(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Watcher paths must not be empty.'); + + Option::fromConfig(['watch' => ['']], $this->tempDir); + } + + public function testFromConfigRejectsAnAbsoluteWatchEntry(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Watcher paths must be relative to the application base path.'); + + Option::fromConfig(['watch' => ['/app']], $this->tempDir); + } + + public function testFromConfigRequiresAtLeastOneWatchPath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The watcher requires at least one watch path.'); + + Option::fromConfig([], $this->tempDir); + } + + public function testFromConfigRejectsAnExplicitlyEmptyWatchList(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The watcher requires at least one watch path.'); + + Option::fromConfig(['watch' => []], $this->tempDir); + } + + public function testFromConfigAcceptsCommandLinePathsWithoutConfiguredPaths(): void + { + $option = Option::fromConfig([], $this->tempDir, extraPaths: ['app']); + + $this->assertSame('app', $option->getWatchPaths()[0]->path); + } + public function testFromConfigUsesDefaultDriver(): void { - $option = Option::fromConfig([], $this->tempDir); + $option = Option::fromConfig(['watch' => ['.env']], $this->tempDir); $this->assertSame(ScanFileDriver::class, $option->getDriver()); } public function testFromConfigUsesConfiguredDriver(): void { - $option = Option::fromConfig(['driver' => FswatchDriver::class], $this->tempDir); + $option = Option::fromConfig([ + 'driver' => FswatchDriver::class, + 'watch' => ['.env'], + ], $this->tempDir); $this->assertSame(FswatchDriver::class, $option->getDriver()); } public function testFromConfigUsesDefaultScanInterval(): void { - $option = Option::fromConfig([], $this->tempDir); + $option = Option::fromConfig(['watch' => ['.env']], $this->tempDir); $this->assertSame(2000, $option->getScanInterval()); } @@ -196,7 +292,7 @@ public function testFromConfigUsesDefaultScanInterval(): void public function testShippedConfigMatchesSourceDefaults(): void { $config = require dirname(__DIR__, 2) . '/src/watcher/config/watcher.php'; - $option = Option::fromConfig([], $this->tempDir); + $option = Option::fromConfig(['watch' => ['.env']], $this->tempDir); $this->assertSame($option->getDriver(), $config['driver']); $this->assertSame($option->getScanInterval(), $config['scan_interval']); @@ -204,7 +300,10 @@ public function testShippedConfigMatchesSourceDefaults(): void public function testFromConfigUsesConfiguredScanInterval(): void { - $option = Option::fromConfig(['scan_interval' => 1500], $this->tempDir); + $option = Option::fromConfig([ + 'watch' => ['.env'], + 'scan_interval' => 1500, + ], $this->tempDir); $this->assertSame(1500, $option->getScanInterval()); } @@ -222,7 +321,10 @@ public function testFromConfigRejectsNonPositiveScanInterval(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The watcher scan interval must be greater than 0.'); - Option::fromConfig(['scan_interval' => -1], $this->tempDir); + Option::fromConfig([ + 'watch' => ['.env'], + 'scan_interval' => -1, + ], $this->tempDir); } public function testScanIntervalSecondsConversion(): void @@ -271,6 +373,7 @@ public function testGlobWithNoBaseDir(): void $this->assertSame('.', $paths[0]->path); $this->assertSame(WatchPathType::Directory, $paths[0]->type); $this->assertSame('**/*.php', $paths[0]->pattern); + $this->assertTrue($paths[0]->recursive); } public function testConstructorDirectlyWithWatchPaths(): void diff --git a/tests/Watcher/ServerRestartStrategyTest.php b/tests/Watcher/ServerRestartStrategyTest.php index 87006791b1..a2604a5953 100644 --- a/tests/Watcher/ServerRestartStrategyTest.php +++ b/tests/Watcher/ServerRestartStrategyTest.php @@ -13,13 +13,26 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Throwable; class ServerRestartStrategyTest extends TestCase { - public function testConstructorDoesNotRequirePidFileConfiguration(): void + public function testConstructorDefaultsMissingDaemonizeSettingToFalse(): void + { + $this->app->instance('config', new Repository([ + 'server' => ['settings' => ['pid_file' => '/tmp/test.pid']], + 'watcher' => ['bin' => PHP_BINARY, 'command' => ['artisan', 'serve']], + ])); + + $strategy = new ServerRestartStrategy($this->app, new NullOutput); + + $this->assertInstanceOf(ServerRestartStrategy::class, $strategy); + } + + public function testConstructorAcceptsExplicitForegroundMode(): void { $this->app->instance('config', new Repository([ 'server' => ['settings' => ['daemonize' => false]], @@ -104,12 +117,89 @@ public static function invalidCommandProvider(): array public function testStopIsIdempotentWithoutAnOwnedProcess(): void { - $strategy = $this->createProbeStrategy(); + $output = new BufferedOutput; + $strategy = $this->createProbeStrategy($output); $strategy->stop(); $strategy->stop(); $this->assertSame([], $strategy->signals); + $this->assertSame('', $output->fetch()); + } + + public function testSuccessfulSignalPrintsOnlyTheStopMessage(): void + { + $output = new BufferedOutput; + $strategy = $this->createProbeStrategy($output); + $strategy->publishProcessIdForTest(1000); + + $strategy->terminateForTest(); + + $this->assertSame([[1000, SIGTERM]], $strategy->signals); + $this->assertSame("Stop server...\n", $output->fetch()); + } + + #[DataProvider('signalFailureProvider')] + public function testFailedSignalsAreReported(false|Throwable $outcome): void + { + $output = new BufferedOutput; + $strategy = $this->createProbeStrategy($output); + $strategy->publishProcessIdForTest(1000); + $strategy->signalOutcomes = [$outcome]; + + $strategy->terminateForTest(); + + $this->assertSame([[1000, SIGTERM]], $strategy->signals); + $this->assertSame("Stop server...\nStop server failed.\n", $output->fetch()); + } + + public static function signalFailureProvider(): array + { + return [ + 'native failure' => [false], + 'thrown failure' => [new RuntimeException('expected signal failure')], + ]; + } + + public function testStopOutputFailureDoesNotPreventSignalling(): void + { + $output = new class extends NullOutput { + public function writeln( + string|iterable $messages, + int $options = self::OUTPUT_NORMAL, + ): void { + throw new RuntimeException('expected output failure'); + } + }; + $strategy = $this->createProbeStrategy($output); + $strategy->publishProcessIdForTest(1000); + + $strategy->terminateForTest(); + + $this->assertSame([[1000, SIGTERM]], $strategy->signals); + } + + public function testStopSignalsTheCurrentProcessAfterOutputChangesOwnership(): void + { + $output = new class extends NullOutput { + public ?ServerRestartStrategyProbe $strategy = null; + + public function writeln( + string|iterable $messages, + int $options = self::OUTPUT_NORMAL, + ): void { + if ($messages === 'Stop server...') { + $this->strategy?->publishProcessIdForTest(2000); + } + } + }; + $strategy = $this->createProbeStrategy($output); + $output->strategy = $strategy; + $strategy->publishProcessIdForTest(1000); + + $strategy->terminateForTest(); + + $this->assertSame([[2000, SIGTERM]], $strategy->signals); } public function testStopSignalsTheExactOwnedProcess(): void @@ -403,6 +493,9 @@ class ServerRestartStrategyProbe extends ServerRestartStrategy /** @var list */ public array $signals = []; + /** @var list */ + public array $signalOutcomes = []; + public int $openCalls = 0; public int $reloadCalls = 0; @@ -474,7 +567,13 @@ protected function signalProcess(int $pid, int $signal): bool { $this->signals[] = [$pid, $signal]; - return true; + $outcome = array_shift($this->signalOutcomes) ?? true; + + if ($outcome instanceof Throwable) { + throw $outcome; + } + + return $outcome; } protected function reloadEnvironment(): void @@ -500,6 +599,22 @@ public function publishedProcessId(): ?int return $this->processId; } + /** + * Publish a process ID for a termination test. + */ + public function publishProcessIdForTest(?int $processId): void + { + $this->processId = $processId; + } + + /** + * Terminate the published process for a test. + */ + public function terminateForTest(): void + { + $this->terminateServer(); + } + /** * Block the next process creation before PID publication. * diff --git a/tests/Watcher/WatchCommandTest.php b/tests/Watcher/WatchCommandTest.php index 1c0e92970d..f9db5b968a 100644 --- a/tests/Watcher/WatchCommandTest.php +++ b/tests/Watcher/WatchCommandTest.php @@ -18,7 +18,9 @@ use Hypervel\Watcher\WatchPath; use Mockery as m; use RuntimeException; +use Symfony\Component\Console\Exception\RuntimeException as ConsoleRuntimeException; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\NullOutput; class WatchCommandTest extends TestCase @@ -200,6 +202,17 @@ public function testWatchCommandWithExtraPaths(): void $this->assertContains('composer.json', $filePathStrings); } + public function testPathOptionRequiresAValue(): void + { + $command = new WatchCommand(m::mock(Container::class)); + $input = new StringInput('--path'); + + $this->expectException(ConsoleRuntimeException::class); + $this->expectExceptionMessage('The "--path" option requires a value.'); + + $input->bind($command->getDefinition()); + } + /** * Run a watch command that captures its termination signal handler. */ diff --git a/tests/Watcher/WatchPathTest.php b/tests/Watcher/WatchPathTest.php index 462ba658ed..a2742839e1 100644 --- a/tests/Watcher/WatchPathTest.php +++ b/tests/Watcher/WatchPathTest.php @@ -7,6 +7,7 @@ use Hypervel\Tests\TestCase; use Hypervel\Watcher\WatchPath; use Hypervel\Watcher\WatchPathType; +use PHPUnit\Framework\Attributes\DataProvider; class WatchPathTest extends TestCase { @@ -125,4 +126,30 @@ public function testMatchesRootLevelGlob(): void $this->assertTrue($path->matches('artisan.php')); $this->assertFalse($path->matches('app/Foo.php')); } + + #[DataProvider('recursiveWatchPaths')] + public function testDeterminesWhetherTraversalMustBeRecursive( + string $path, + WatchPathType $type, + ?string $pattern, + bool $recursive, + ): void { + $this->assertSame($recursive, (new WatchPath($path, $type, $pattern))->recursive); + } + + public static function recursiveWatchPaths(): array + { + return [ + 'plain directory' => ['app', WatchPathType::Directory, null, true], + 'exact file' => ['.env', WatchPathType::File, null, false], + 'root file glob' => ['.', WatchPathType::Directory, '.env*', false], + 'shallow file glob' => ['app', WatchPathType::Directory, 'app/Foo*.php', false], + 'question-mark glob' => ['routes', WatchPathType::Directory, 'routes/?.php', false], + 'brace glob' => ['config', WatchPathType::Directory, 'config/{app,queue}.php', false], + 'middle directory glob' => ['app', WatchPathType::Directory, 'app/*/Actions/*.php', true], + 'double-star suffix' => ['app', WatchPathType::Directory, 'app/**', true], + 'recursive glob' => ['app', WatchPathType::Directory, 'app/**/*.php', true], + 'root recursive glob' => ['.', WatchPathType::Directory, '**/*.php', true], + ]; + } }