diff --git a/.github/actions/watcher/action.yaml b/.github/actions/watcher/action.yaml deleted file mode 100644 index 6610d65f56..0000000000 --- a/.github/actions/watcher/action.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: watcher -description: Install e-dant/watcher -runs: - using: composite - steps: - - name: Determine e-dant/watcher version - id: determine-watcher-version - run: echo version="$(gh release view --repo e-dant/watcher --json tagName --template '{{ .tagName }}')" >> "${GITHUB_OUTPUT}" - shell: bash - env: - GH_TOKEN: ${{ github.token }} - - name: Cache e-dant/watcher - id: cache-watcher - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: watcher/target - key: watcher-${{ runner.os }}-${{ runner.arch }}-${{ steps.determine-watcher-version.outputs.version }}-${{ env.CC && env.CC || 'gcc' }} - - if: steps.cache-watcher.outputs.cache-hit != 'true' - name: Compile e-dant/watcher - run: | - mkdir -p watcher - rm -rf watcher/* - gh release download --repo e-dant/watcher -A tar.gz -O - | tar -xz -C watcher --strip-components 1 - cd watcher - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release - cmake --build build - sudo cmake --install build --prefix target - shell: bash - env: - GH_TOKEN: ${{ github.token }} - - name: Update LD_LIBRARY_PATH - run: | - sudo sh -c "echo ${PWD}/watcher/target/lib > /etc/ld.so.conf.d/watcher.conf" - sudo ldconfig - shell: bash diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3ea099d2ff..e040cb71d5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -161,9 +161,6 @@ jobs: cache: "false" - if: steps.state.outputs.resume != 'true' uses: ./.github/actions/setup-php - - if: steps.state.outputs.resume != 'true' - name: Install e-dant/watcher - uses: ./.github/actions/watcher - if: steps.state.outputs.resume != 'true' name: Set CGO flags run: echo "CGO_CFLAGS=-I${PWD}/watcher/target/include $(php-config --includes)" >> "${GITHUB_ENV}" diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index 0f6e2e2c7c..49c8b7ce2e 100644 --- a/.github/workflows/sanitizers.yaml +++ b/.github/workflows/sanitizers.yaml @@ -96,8 +96,6 @@ jobs: URL: ${{ steps.determine-php-version.outputs.archive }} - name: Add PHP to the PATH run: echo "$(pwd)/php/target/bin" >> "$GITHUB_PATH" - - name: Install e-dant/watcher - uses: ./.github/actions/watcher - name: Install gotestsum run: go install gotest.tools/gotestsum@latest - name: Set CGO flags diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 0d9a0c04eb..866e2ecea1 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -47,8 +47,6 @@ jobs: - uses: ./.github/actions/setup-php with: php-version: ${{ matrix.php-versions }} - - name: Install e-dant/watcher - uses: ./.github/actions/watcher - name: Reinstall libbrotli-dev # TODO: remove this workaround when fixed upstream run: sudo apt-get install --reinstall -y libbrotli-dev diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index c45cfb1437..63b79f4e47 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -98,18 +98,6 @@ jobs: working-directory: frankenphp run: "vcpkg install" - - name: Download Watcher - run: | - $latestTag = gh release list --repo e-dant/watcher --limit 1 --exclude-drafts --exclude-pre-releases --json tagName --jq '.[0].tagName' - Write-Host "Latest Watcher version: $latestTag" - - gh release download $latestTag --repo e-dant/watcher --pattern "*x86_64-pc-windows-msvc.tar" -O watcher.tar - - tar -xf "watcher.tar" -C "$env:GITHUB_WORKSPACE" - Rename-Item -Path "$env:GITHUB_WORKSPACE\x86_64-pc-windows-msvc" -NewName "watcher" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Download PHP run: | $webContent = Invoke-WebRequest -Uri $env:PHP_DOWNLOAD_BASE @@ -150,7 +138,7 @@ jobs: "$phpBin" >> $env:GITHUB_PATH "CGO_CFLAGS=-DFRANKENPHP_VERSION=$env:FRANKENPHP_VERSION -I$vcpkgRoot\include -I$watcherRoot -I$phpDevel\include -I$phpDevel\include\main -I$phpDevel\include\TSRM -I$phpDevel\include\Zend -I$phpDevel\include\ext" >> $env:GITHUB_ENV - "CGO_LDFLAGS=-L$vcpkgRoot\lib -lbrotlienc -L$watcherRoot -llibwatcher-c -L$phpBin -L$phpDevel\lib -lphp8ts -lphp8embed" >> $env:GITHUB_ENV + "CGO_LDFLAGS=-L$vcpkgRoot\lib -lbrotlienc -L$phpBin -L$phpDevel\lib -lphp8ts -lphp8embed" >> $env:GITHUB_ENV env: FRANKENPHP_VERSION: ${{ steps.version.outputs.version }} @@ -206,7 +194,6 @@ jobs: if: env.BUILD_RELEASE == 'true' run: | Copy-Item frankenphp\caddy\frankenphp\frankenphp.exe $env:DIR_NAME - Copy-Item watcher\libwatcher-c.dll $env:DIR_NAME Copy-Item frankenphp\vcpkg_installed\x64-windows\bin\brotlienc.dll $env:DIR_NAME Copy-Item frankenphp\vcpkg_installed\x64-windows\bin\brotlidec.dll $env:DIR_NAME Copy-Item frankenphp\vcpkg_installed\x64-windows\bin\brotlicommon.dll $env:DIR_NAME diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9534ec101..c16ccf944c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -113,32 +113,31 @@ curl -v http://127.0.0.1:8080/phpinfo.php .\vcpkg\bootstrap-vcpkg.bat ``` -4. [Download the latest version of the watcher library for Windows](https://github.com/e-dant/watcher/releases) and extract it to a directory named `C:\watcher` -5. [Download the latest **Thread Safe** version of PHP and of the PHP SDK for Windows](https://windows.php.net/download/), extract them in directories named `C:\php` and `C:\php-devel` -6. Clone the FrankenPHP Git repository: +4. [Download the latest **Thread Safe** version of PHP and of the PHP SDK for Windows](https://windows.php.net/download/), extract them in directories named `C:\php` and `C:\php-devel` +5. Clone the FrankenPHP Git repository: ```powershell git clone https://github.com/php/frankenphp C:\frankenphp cd C:\frankenphp ``` -7. Install the dependencies: +6. Install the dependencies: ```powershell C:\vcpkg\vcpkg.exe install ``` -8. Configure the needed environment variables (PowerShell): +7. Configure the needed environment variables (PowerShell): ```powershell $env:PATH += ';C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\Llvm\bin' $env:CC = 'clang' $env:CXX = 'clang++' $env:CGO_CFLAGS = "-O0 -g -IC:\frankenphp\vcpkg_installed\x64-windows\include -IC:\watcher -IC:\php-devel\include -IC:\php-devel\include\main -IC:\php-devel\include\TSRM -IC:\php-devel\include\Zend -IC:\php-devel\include\ext" - $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -llibwatcher-c -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' + $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' ``` -9. Run the tests: +8. Run the tests: ```powershell go test -race -ldflags '-extldflags="-fuse-ld=lld"' ./... @@ -147,7 +146,7 @@ curl -v http://127.0.0.1:8080/phpinfo.php cd .. ``` -10. Build the binary: +9. Build the binary: ```powershell cd caddy/frankenphp diff --git a/Dockerfile b/Dockerfile index 6697209d38..a7cf5c65d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,28 +81,6 @@ RUN apt-get update && \ && \ apt-get clean -# Install e-dant/watcher (necessary for file watching) -WORKDIR /usr/local/src/watcher -RUN --mount=type=secret,id=github-token <<'EOF' -set -e -api=https://api.github.com/repos/e-dant/watcher/releases/latest -if [ -s /run/secrets/github-token ]; then - tarball_url=$(curl -fsSL -H "Authorization: Bearer $(cat /run/secrets/github-token)" "${api}" | jq -r '.tarball_url // empty') -else - tarball_url=$(curl -fsSL "${api}" | jq -r '.tarball_url // empty') -fi -if [ -z "${tarball_url}" ]; then - echo "failed to resolve e-dant/watcher tarball URL (rate limited?)" >&2 - exit 1 -fi -curl -fsSL "${tarball_url}" | tar xz --strip-components 1 -# -Wno-error=use-after-free: GCC 12 on Bookworm i386 emits a spurious warning in libstdc++ basic_string.h -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_FLAGS="-Wno-error=use-after-free" -cmake --build build -cmake --install build -ldconfig -EOF - WORKDIR /go/src/app COPY --link go.mod go.sum ./ @@ -135,15 +113,6 @@ FROM common AS runner ENV GODEBUG=cgocheck=0 -# copy watcher shared library -COPY --from=builder /usr/local/lib/libwatcher* /usr/local/lib/ -# fix for the file watcher on arm -RUN apt-get install -y --no-install-recommends libstdc++6 && \ - apt-get clean && \ - ldconfig && \ - # Remove files with non-deterministic content (embedded timestamps and mtimes) to keep image digests reproducible - rm -rf /var/log/apt /var/log/dpkg.log /var/log/alternatives.log /var/cache/ldconfig/aux-cache - COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp RUN setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp && \ frankenphp version && \ diff --git a/alpine.Dockerfile b/alpine.Dockerfile index e9e0fb3176..db98dd412c 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -77,8 +77,6 @@ RUN apk add --no-cache --virtual .build-deps \ jq \ libsodium-dev \ # Needed for the file watcher \ - cmake \ - libstdc++ \ libxml2-dev \ linux-headers \ oniguruma-dev \ @@ -87,26 +85,6 @@ RUN apk add --no-cache --virtual .build-deps \ sqlite-dev \ upx -# Install e-dant/watcher (necessary for file watching) -WORKDIR /usr/local/src/watcher -RUN --mount=type=secret,id=github-token <<'EOF' -set -e -api=https://api.github.com/repos/e-dant/watcher/releases/latest -if [ -s /run/secrets/github-token ]; then - tarball_url=$(curl -fsSL -H "Authorization: Bearer $(cat /run/secrets/github-token)" "${api}" | jq -r '.tarball_url // empty') -else - tarball_url=$(curl -fsSL "${api}" | jq -r '.tarball_url // empty') -fi -if [ -z "${tarball_url}" ]; then - echo "failed to resolve e-dant/watcher tarball URL (rate limited?)" >&2 - exit 1 -fi -curl -fsSL "${tarball_url}" | tar xz --strip-components 1 -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build -cmake --install build -EOF - WORKDIR /go/src/app COPY --link go.mod go.sum ./ @@ -139,11 +117,6 @@ FROM common AS runner ENV GODEBUG=cgocheck=0 -# copy watcher shared library (libgcc and libstdc++ are needed for the watcher) -COPY --from=builder /usr/local/lib/libwatcher* /usr/local/lib/ -RUN apk add --no-cache libstdc++ && \ - ldconfig /usr/local/lib - COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp RUN setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp && \ frankenphp version && \ diff --git a/caddy/go.mod b/caddy/go.mod index 74ead77b02..fcd08095ec 100644 --- a/caddy/go.mod +++ b/caddy/go.mod @@ -63,7 +63,6 @@ require ( github.com/dunglas/skipfilter v1.2.0 // indirect github.com/dunglas/vulcain v1.4.5 // indirect github.com/dustin/go-humanize v1.1.0 // indirect - github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be // indirect github.com/elnormous/contenttype v1.0.4 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fxamacker/cbor/v2 v2.9.6 // indirect @@ -128,6 +127,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + github.com/sgtdi/fswatcher v1.3.0 // indirect github.com/shopspring/decimal v1.5.0 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/sirupsen/logrus v1.10.2 // indirect diff --git a/caddy/go.sum b/caddy/go.sum index 29971dd6bb..85e341cb82 100644 --- a/caddy/go.sum +++ b/caddy/go.sum @@ -170,8 +170,6 @@ github.com/dunglas/vulcain/caddy v1.4.5/go.mod h1:HFvLJVVuRG7JQJr+rUBWJnYxHDVn1O github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= -github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be h1:vqHrvilasyJcnru/0Z4FoojsQJUIfXGVplte7JtupfY= -github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0= github.com/elnormous/contenttype v1.0.4 h1:FjmVNkvQOGqSX70yvocph7keC8DtmJaLzTTq6ZOQCI8= github.com/elnormous/contenttype v1.0.4/go.mod h1:5KTOW8m1kdX1dLMiUJeN9szzR2xkngiv2K+RVZwWBbI= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= @@ -356,6 +354,8 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57Hu github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E= github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg= +github.com/sgtdi/fswatcher v1.3.0 h1:2tFEnBml5EipRF4TvUP0x+T4ty2OSYlmvcnQ6dSTp04= +github.com/sgtdi/fswatcher v1.3.0/go.mod h1:I4FUeG0e27WFw+ogs5OjZSgPKobnGrUa17EwjRjZQaY= github.com/shopspring/decimal v1.5.0 h1:HxSWk31RmJv0EyY4oLiGQB9u7WPludUwPBdvQZnYFPc= github.com/shopspring/decimal v1.5.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= diff --git a/dev-alpine.Dockerfile b/dev-alpine.Dockerfile index b3b63c1e31..4c2a45c51f 100644 --- a/dev-alpine.Dockerfile +++ b/dev-alpine.Dockerfile @@ -32,8 +32,6 @@ RUN apk add --no-cache \ zlib-dev \ bison \ nss-tools \ - # file watcher \ - libstdc++ \ linux-headers \ # Dev tools \ git \ @@ -68,13 +66,6 @@ RUN git clone --branch=PHP-8.5 https://github.com/php/php-src.git . && \ echo "opcache.enable=1" >> /etc/frankenphp/php.ini && \ php --version -# Install e-dant/watcher (necessary for file watching) -WORKDIR /usr/local/src/watcher -RUN git clone https://github.com/e-dant/watcher . && \ - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ - cmake --build build/ && \ - cmake --install build - WORKDIR /go/src/app COPY . . diff --git a/dev.Dockerfile b/dev.Dockerfile index d6ee769e88..e6dccf90e2 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -70,15 +70,6 @@ RUN git clone --branch=PHP-8.5 https://github.com/php/php-src.git . && \ echo "opcache.enable=1" >> /etc/frankenphp/php.ini && \ php --version -# Install e-dant/watcher (necessary for file watching) -WORKDIR /usr/local/src/watcher -RUN git clone https://github.com/e-dant/watcher . && \ - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && \ - cmake --build build/ && \ - cmake --install build && \ - cp build/libwatcher-c.so /usr/local/lib/libwatcher-c.so && \ - ldconfig - WORKDIR /go/src/app COPY --link . ./ diff --git a/docs/compile.md b/docs/compile.md index ac77f29b5c..4224596ea2 100644 --- a/docs/compile.md +++ b/docs/compile.md @@ -91,7 +91,7 @@ Alternatively, these features can be disabled by passing build tags to the Go co | Feature | Dependency | Build tag to disable it | | ------------------------------ | ------------------------------------------------------------------------------------------------------------ | ----------------------- | | Brotli compression | [Brotli](https://github.com/google/brotli) | nobrotli | -| Restart workers on file change | [Watcher C](https://github.com/e-dant/watcher/tree/release/watcher-c) | nowatcher | +| Restart workers on file change | [Watcher C](https://github.com/sgtdi/fswatcher) | nowatcher | | [Mercure](mercure.md) | [Mercure Go library](https://pkg.go.dev/github.com/dunglas/mercure) (automatically installed, AGPL licensed) | nomercure | ## Compile the Go app diff --git a/docs/config.md b/docs/config.md index 3dc5a9cbbe..94b75a046c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -248,8 +248,6 @@ where the FrankenPHP process was started. You can instead also specify one or mo - If you have multiple workers defined, all of them will be restarted when a file changes - Be wary about watching files that are created at runtime (like logs) since they might cause unwanted worker restarts. -The file watcher is based on [e-dant/watcher](https://github.com/e-dant/watcher). - ## Matching the worker to a path In traditional PHP applications, scripts are always placed in the public directory. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index d1eff0b49d..c33191ddc0 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -149,7 +149,7 @@ php_server { ## How FrankenPHP hot reload works -1. **Watch**: FrankenPHP monitors the filesystem for modifications using [the `e-dant/watcher` library](https://github.com/e-dant/watcher) under the hood (we contributed the Go binding). +1. **Watch**: FrankenPHP monitors the filesystem for modifications using [the `sgtdi/fswatcher` library](https://github.com/sgtdi/fswatcher) under the hood. 2. **Restart (Worker Mode)**: if `watch` is enabled in the worker config, the PHP worker is restarted to load the new code. 3. **Push**: a JSON payload containing the list of changed files is sent to the built-in [Mercure hub](https://mercure.rocks). 4. **Receive**: The browser, listening via the JavaScript library, receives the Mercure event. diff --git a/docs/it/CONTRIBUTING.md b/docs/it/CONTRIBUTING.md index 81df0ca395..cf6e8561de 100644 --- a/docs/it/CONTRIBUTING.md +++ b/docs/it/CONTRIBUTING.md @@ -135,7 +135,7 @@ curl -v http://127.0.0.1:8080/phpinfo.php $env:CC = 'clang' $env:CXX = 'clang++' $env:CGO_CFLAGS = "-O0 -g -IC:\frankenphp\vcpkg_installed\x64-windows\include -IC:\watcher -IC:\php-devel\include -IC:\php-devel\include\main -IC:\php-devel\include\TSRM -IC:\php-devel\include\Zend -IC:\php-devel\include\ext" - $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -llibwatcher-c -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' + $env:CGO_LDFLAGS = '-LC:\frankenphp\vcpkg_installed\x64-windows\lib -lbrotlienc -LC:\watcher -LC:\php -LC:\php-devel\lib -lphp8ts -lphp8embed' ``` 9. Eseguire i test: diff --git a/go.mod b/go.mod index c94eed6eae..b3a29720f0 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,9 @@ retract v1.0.0-rc.1 // Human error require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/dunglas/mercure v1.1.1 - github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be github.com/maypok86/otter/v2 v2.3.0 github.com/prometheus/client_golang v1.25.0 + github.com/sgtdi/fswatcher v1.3.0 github.com/stretchr/testify v1.12.1 golang.org/x/net v0.61.0 golang.org/x/sys v0.49.0 diff --git a/go.sum b/go.sum index 07af36776d..702a9fdb4e 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,6 @@ github.com/dunglas/mercure v1.1.1 h1:dcgO9E9bPY/6ZIjyCL+zJz9mFxgP0KQ2mCUjcaUXzR0 github.com/dunglas/mercure v1.1.1/go.mod h1:kI5cNv6Q/Ky7pYaHfFkzksYosGUFNDo/JwxRQs37Lw4= github.com/dunglas/skipfilter v1.2.0 h1:J7/RXRxEx3t+DEu2Zt8ISsRuptbUFOWHEIzWIjraqRM= github.com/dunglas/skipfilter v1.2.0/go.mod h1:ZVvGnWsdN5Hdwt27Wbi98ixUeFF3yLZ/WsFtcWQBN3s= -github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be h1:vqHrvilasyJcnru/0Z4FoojsQJUIfXGVplte7JtupfY= -github.com/e-dant/watcher v0.0.0-20260223030516-06f84a1314be/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0= github.com/elnormous/contenttype v1.0.4 h1:FjmVNkvQOGqSX70yvocph7keC8DtmJaLzTTq6ZOQCI8= github.com/elnormous/contenttype v1.0.4/go.mod h1:5KTOW8m1kdX1dLMiUJeN9szzR2xkngiv2K+RVZwWBbI= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -74,6 +72,8 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sgtdi/fswatcher v1.3.0 h1:2tFEnBml5EipRF4TvUP0x+T4ty2OSYlmvcnQ6dSTp04= +github.com/sgtdi/fswatcher v1.3.0/go.mod h1:I4FUeG0e27WFw+ogs5OjZSgPKobnGrUa17EwjRjZQaY= github.com/shopspring/decimal v1.5.0 h1:HxSWk31RmJv0EyY4oLiGQB9u7WPludUwPBdvQZnYFPc= github.com/shopspring/decimal v1.5.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= diff --git a/hotreload.go b/hotreload.go index dde11a6cf1..0a4150c11b 100644 --- a/hotreload.go +++ b/hotreload.go @@ -8,7 +8,7 @@ import ( "github.com/dunglas/frankenphp/internal/watcher" "github.com/dunglas/mercure" - watcherGo "github.com/e-dant/watcher/watcher-go" + "github.com/sgtdi/fswatcher" ) // WithHotReload sets files to watch for file changes to trigger a hot reload update. @@ -16,7 +16,7 @@ func WithHotReload(topic string, hub *mercure.Hub, patterns []string) Option { return func(o *opt) error { o.hotReload = append(o.hotReload, &watcher.PatternGroup{ Patterns: patterns, - Callback: func(events []*watcherGo.Event) { + Callback: func(events []fswatcher.WatchEvent) { // Wait for workers to restart before sending the update go func() { data, err := json.Marshal(events) diff --git a/internal/watcher/pattern.go b/internal/watcher/pattern.go index 0f0bf3603e..42ff7b4363 100644 --- a/internal/watcher/pattern.go +++ b/internal/watcher/pattern.go @@ -3,231 +3,172 @@ package watcher import ( - "log/slog" + "fmt" "path/filepath" + "regexp" "strings" "github.com/dunglas/frankenphp/internal/fastabs" - "github.com/e-dant/watcher/watcher-go" ) -const sep = string(filepath.Separator) +func extractRootAndRegex(pattern string) (string, string, error) { + forceDir := strings.HasSuffix(pattern, "/") || strings.HasSuffix(pattern, string(filepath.Separator)) -type pattern struct { - patternGroup *PatternGroup - value string - parsedValues []string - events chan eventHolder - failureCount int - - watcher *watcher.Watcher -} - -func (p *pattern) startSession() { - p.watcher = watcher.NewWatcher(p.value, p.handle) - - if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "watching", slog.String("pattern", p.value)) - } -} - -// this method prepares the pattern struct (aka /path/*pattern) -func (p *pattern) parse() (err error) { - // first we clean the value - absPattern, err := fastabs.FastAbs(p.value) + absPattern, err := fastabs.FastAbs(pattern) if err != nil { - return err + return "", "", err } - p.value = absPattern - - volumeName := filepath.VolumeName(p.value) - p.value = strings.TrimPrefix(p.value, volumeName) - - // then we split the pattern to determine where the directory ends and the pattern starts - splitPattern := strings.Split(p.value, sep) - patternWithoutDir := "" - for i, part := range splitPattern { - isFilename := i == len(splitPattern)-1 && strings.Contains(part, ".") - isGlobCharacter := strings.ContainsAny(part, "[*?{") - - if isFilename || isGlobCharacter { - patternWithoutDir = filepath.Join(splitPattern[i:]...) - p.value = filepath.Join(splitPattern[:i]...) - - break - } - } - - // now we split the pattern according to the recursive '**' syntax - p.parsedValues = strings.Split(patternWithoutDir, "**") - for i, pp := range p.parsedValues { - p.parsedValues[i] = strings.Trim(pp, sep) - } - - // remove the trailing separator and add leading separator (except on Windows) - if volumeName == "" { - p.value = sep + strings.Trim(p.value, sep) - } else { - p.value = volumeName + sep + strings.Trim(p.value, sep) - } + absPattern = filepath.ToSlash(absPattern) - // try to canonicalize the path - canonicalPattern, err := filepath.EvalSymlinks(p.value) - if err == nil { - p.value = canonicalPattern + segs := strings.Split(filepath.ToSlash(absPattern), "/") + n := 0 + for n < len(segs) && !strings.ContainsAny(segs[n], `*?[{\`) { + n++ } - return nil -} + // No wildcard anywhere: the pattern is a concrete path. + if n == len(segs) { + // ".x" counts as an extension, a bare "." (e.g. "foo.") does not. + isFile := !forceDir && len(filepath.Ext(absPattern)) > 1 + if isFile { + // watch the parent, but filter only for the exact filename + return filepath.Dir(absPattern), "^" + regexp.QuoteMeta(absPattern) + "$", nil + } -func (p *pattern) allowReload(event *watcher.Event) bool { - if !isValidEventType(event.EffectType) || !isValidPathType(event) { - return false + // watch the whole directory + return absPattern, "^" + regexp.QuoteMeta(absPattern), nil } - // some editors create temporary files and never actually modify the original file - // so we need to also check Event.AssociatedPathName - // see https://github.com/php/frankenphp/issues/1375 - return p.isValidPattern(event.PathName) || p.isValidPattern(event.AssociatedPathName) -} - -func (p *pattern) handle(event *watcher.Event) { - // If the watcher prematurely sends the die@ event, retry watching - if event.PathType == watcher.PathTypeWatcher && strings.HasPrefix(event.PathName, "e/self/die@") && watcherIsActive.Load() { - p.retryWatching() - - return + // Wildcard present: watch the topmost static directory. + dir := strings.Join(segs[:n], "/") + if dir == filepath.VolumeName(dir) { + // "" on Unix, "C:" on Windows: watch the root, not the drive's current dir + dir += "/" } - if p.allowReload(event) { - p.events <- eventHolder{p.patternGroup, event} + regex, err := wildcardToRegex(absPattern) + if err != nil { + return "", "", err } -} - -func (p *pattern) stop() { - p.watcher.Close() -} -func isValidEventType(effectType watcher.EffectType) bool { - return effectType <= watcher.EffectTypeDestroy + return filepath.FromSlash(dir), regex, nil } -func isValidPathType(event *watcher.Event) bool { - if event.PathType == watcher.PathTypeWatcher && globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "special e-dant/watcher event", slog.Any("event", event)) +func wildcardToRegex(pattern string) (string, error) { + pattern, err := fastabs.FastAbs(pattern) + if err != nil { + return "", err } - return event.PathType <= watcher.PathTypeHardLink -} - -func (p *pattern) isValidPattern(fileName string) bool { - if fileName == "" { - return false - } + // Paths are matched in slash form (windows compatibility) + pattern = filepath.ToSlash(pattern) - // first we remove the dir from the file name - if !strings.HasPrefix(fileName, p.value) { - return false - } + var sb strings.Builder + sb.WriteString("^") - // remove the directory path and separator from the filename - fileNameWithoutDir := strings.TrimPrefix(strings.TrimPrefix(fileName, p.value), sep) + runes := []rune(pattern) + braceDepth := 0 - // if the pattern has size 1 we can match it directly against the filename - if len(p.parsedValues) == 1 { - return matchCurlyBracePattern(p.parsedValues[0], fileNameWithoutDir) - } + for i := 0; i < len(runes); i++ { + c := runes[i] - return p.matchPatterns(fileNameWithoutDir) -} - -func (p *pattern) matchPatterns(fileName string) bool { - partsToMatch := strings.Split(fileName, sep) - cursor := 0 + switch c { + case '*': + // Consume a run of stars. + start := i + for i+1 < len(runes) && runes[i+1] == '*' { + i++ + } + isDouble := i > start + + switch { + case !isDouble: + // plain "*" -> anything except a separator + sb.WriteString("[^/]*") + + case i+1 < len(runes) && runes[i+1] == '/' && + (start == 0 || runes[start-1] == '/'): + // "**/" at a segment start -> zero or more directories + i++ // consume the slash + sb.WriteString("(?:.*/)?") + + default: + // any other "**" -> anything, including separators + sb.WriteString(".*") + } - // if there are multiple parsedValues due to '**' we need to match them individually - for i, pattern := range p.parsedValues { - patternSize := strings.Count(pattern, sep) + 1 + case '?': + sb.WriteString("[^/]") - // if we are at the last pattern we will start matching from the end of the filename - if i == len(p.parsedValues)-1 { - cursor = len(partsToMatch) - patternSize + case '{': + braceDepth++ + sb.WriteString("(?:") - if cursor < 0 { - return false + case '}': + if braceDepth == 0 { + sb.WriteString(`\}`) // stray '}' is literal + } else { + braceDepth-- + sb.WriteString(")") } - } - // the cursor will move through the fileName until the pattern matches - for j := cursor; j < len(partsToMatch); j++ { - if j+patternSize > len(partsToMatch) { - return false + case ',': + if braceDepth > 0 { + sb.WriteString("|") + } else { + sb.WriteString(",") } - cursor = j - subPattern := strings.Join(partsToMatch[j:j+patternSize], sep) - - if matchCurlyBracePattern(pattern, subPattern) { - cursor = j + patternSize - 1 - + case '[': + // Find closing bracket. + j := i + 1 + if j < len(runes) && (runes[j] == '!' || runes[j] == '^') { + j++ + } + if j < len(runes) && runes[j] == ']' { // leading ']' is literal + j++ + } + for j < len(runes) && runes[j] != ']' { + j++ + } + if j >= len(runes) { + // No closing bracket: treat '[' as literal. + sb.WriteString(`\[`) break } - - if cursor > len(partsToMatch)-patternSize-1 { - return false + class := runes[i+1 : j] + sb.WriteString("[") + if len(class) > 0 && (class[0] == '!' || class[0] == '^') { + sb.WriteString("^") + class = class[1:] + } + for _, r := range class { + if r == '\\' || r == '[' || r == ']' { + sb.WriteRune('\\') + } + sb.WriteRune(r) + } + sb.WriteString("]") + i = j + + case '\\': + if i+1 < len(runes) { + i++ + sb.WriteString(regexp.QuoteMeta(string(runes[i]))) + } else { + sb.WriteString(`\\`) } - } - } - - return true -} -// we also check for the following syntax: /path/*.{php,twig,yaml} -func matchCurlyBracePattern(pattern string, fileName string) bool { - for _, subPattern := range expandCurlyBraces(pattern) { - if matchPattern(subPattern, fileName) { - return true + default: + sb.WriteString(regexp.QuoteMeta(string(c))) } } - return false -} - -// {dir1,dir2}/path -> []string{"dir1/path", "dir2/path"} -func expandCurlyBraces(s string) []string { - before, rest, found := strings.Cut(s, "{") - if !found { - return []string{s} - } - - inside, after, found := strings.Cut(rest, "}") - if !found { - return []string{s} // no closing brace - } - - var out []string - for subPattern := range strings.SplitSeq(inside, ",") { - out = append(out, expandCurlyBraces(before+subPattern+after)...) - } - - return out -} - -func matchPattern(pattern string, fileName string) bool { - if pattern == "" { - return true - } - - patternMatches, err := filepath.Match(pattern, fileName) - - if err != nil { - if globalLogger.Enabled(globalCtx, slog.LevelError) { - globalLogger.LogAttrs(globalCtx, slog.LevelError, "failed to match filename", slog.String("file", fileName), slog.Any("error", err)) - } - - return false + if braceDepth != 0 { + return "", fmt.Errorf("glob %q: unbalanced '{'", pattern) } - return patternMatches + sb.WriteString("$") + return sb.String(), nil } diff --git a/internal/watcher/pattern_test.go b/internal/watcher/pattern_test.go index 8ee3907c7e..9f29a7ed10 100644 --- a/internal/watcher/pattern_test.go +++ b/internal/watcher/pattern_test.go @@ -4,10 +4,10 @@ package watcher import ( "path/filepath" - "strings" + "regexp" "testing" - "github.com/e-dant/watcher/watcher-go" + "github.com/dunglas/frankenphp/internal/fastabs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -19,39 +19,13 @@ func normalizePath(t *testing.T, path string) string { return path } - path = filepath.FromSlash(path) - if strings.HasPrefix(path, "\\") { - path = "C:\\" + path[1:] - } + // Rooted paths without a volume resolve against the current drive + path, err := filepath.Abs(filepath.FromSlash(path)) + require.NoError(t, err) return path } -func newPattern(t *testing.T, value string) pattern { - t.Helper() - - p := pattern{value: normalizePath(t, value)} - require.NoError(t, p.parse()) - - return p -} - -func TestDisallowOnEventTypeBiggerThan3(t *testing.T) { - t.Parallel() - - w := newPattern(t, "/some/path") - - assert.False(t, w.allowReload(&watcher.Event{PathName: "/some/path/watch-me.php", EffectType: watcher.EffectTypeOwner})) -} - -func TestDisallowOnPathTypeBiggerThan2(t *testing.T) { - t.Parallel() - - w := newPattern(t, "/some/path") - - assert.False(t, w.allowReload(&watcher.Event{PathName: "/some/path/watch-me.php", PathType: watcher.PathTypeSymLink})) -} - func TestWatchesCorrectDir(t *testing.T) { t.Parallel() @@ -217,7 +191,6 @@ func TestInvalidRecursiveFilePatterns(t *testing.T) { {".**/*.php", "/other/file.php"}, {"./**/*.php", "/other/file.php"}, {"/a/**/very/long/path.php", "/a/short.php"}, - {"", ""}, {"/a/**/b/c/d/**/e.php", "/a/x/e.php"}, } @@ -331,7 +304,10 @@ func TestInvalidCurlyBracePatterns(t *testing.T) { {"/path/{dir1,dir2}/**/*.php", "/path/dir1/subpath/file.txt"}, {"/path/{dir1,dir2}/{a,b}{a,b}.php", "/path/dir1/ac.php"}, {"/path/{}/{a,b}{a,b}.php", "/path/dir1/ac.php"}, - {"/path/}dir{/{a,b}{a,b}.php", "/path/dir1/aa.php"}, + } + + errorPatterns := []string{ + "/path/}dir{/{a,b}{a,b}.php", } for _, d := range data { @@ -341,18 +317,14 @@ func TestInvalidCurlyBracePatterns(t *testing.T) { assertPatternNotMatch(t, d.pattern, d.dir) }) } -} - -func TestAnAssociatedEventTriggersTheWatcher(t *testing.T) { - t.Parallel() - w := newPattern(t, "/**/*.php") - w.events = make(chan eventHolder) - - e := &watcher.Event{PathName: normalizePath(t, "/path/temporary_file"), AssociatedPathName: normalizePath(t, "/path/file.php")} - go w.handle(e) + for _, p := range errorPatterns { + t.Run(p, func(t *testing.T) { + t.Parallel() - assert.Equal(t, e, (<-w.events).event) + assertPatternError(t, p) + }) + } } func relativeDir(t *testing.T, relativePath string) string { @@ -367,23 +339,45 @@ func relativeDir(t *testing.T, relativePath string) string { func hasDir(t *testing.T, p string, dir string) { t.Helper() - w := newPattern(t, p) + got, _, err := extractRootAndRegex(p) + require.NoError(t, err) - assert.Equal(t, normalizePath(t, dir), w.value) + assert.Equal(t, normalizePath(t, dir), normalizePath(t, got)) } func assertPatternMatch(t *testing.T, p, fileName string) { t.Helper() - w := newPattern(t, p) - - assert.True(t, w.allowReload(&watcher.Event{PathName: normalizePath(t, fileName)})) + assertPattern(t, p, fileName, true) } func assertPatternNotMatch(t *testing.T, p, fileName string) { t.Helper() - w := newPattern(t, p) + assertPattern(t, p, fileName, false) +} + +func assertPatternError(t *testing.T, p string) { + t.Helper() + + _, _, err := extractRootAndRegex(p) + require.Error(t, err) +} - assert.False(t, w.allowReload(&watcher.Event{PathName: normalizePath(t, fileName)})) +func assertPattern(t *testing.T, p, fileName string, want bool) { + t.Helper() + + _, regex, err := extractRootAndRegex(p) + require.NoError(t, err) + + re := regexp.MustCompile(regex) + absFile, err := fastabs.FastAbs(normalizePath(t, fileName)) + require.NoError(t, err) + absFile = filepath.ToSlash(absFile) + + if want { + assert.True(t, re.MatchString(absFile), "fileName: %s, regex: %s", absFile, regex) + } else { + assert.False(t, re.MatchString(absFile), "fileName: %s, regex: %s", absFile, regex) + } } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 61b5fb81e3..2f05c5d24c 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -6,52 +6,46 @@ import ( "context" "errors" "log/slog" + "path/filepath" + "regexp" + "strings" "sync" "sync/atomic" "time" - "github.com/e-dant/watcher/watcher-go" -) - -const ( - // duration to wait before triggering a reload after a file change - debounceDuration = 150 * time.Millisecond - // times to retry watching if the watcher was closed prematurely - maxFailureCount = 5 - failureResetDuration = 5 * time.Second + "github.com/sgtdi/fswatcher" ) var ( ErrAlreadyStarted = errors.New("watcher is already running") - failureMu sync.Mutex + // the currently active file watcher + activeWatcher *globalWatcher watcherIsActive atomic.Bool - // the currently active file watcher - activeWatcher *globalWatcher - // after stopping the watcher we will wait for eventual reloads to finish - reloadWaitGroup sync.WaitGroup - // we are passing the context from the main package to the watcher - globalCtx context.Context // we are passing the globalLogger from the main package to the watcher - globalLogger *slog.Logger + globalLogger *slog.Logger + globalContext context.Context ) +type compiledPattern struct { + root string + regex *regexp.Regexp // nil: match any path under root +} + type PatternGroup struct { Patterns []string - Callback func([]*watcher.Event) -} + Callback func([]fswatcher.WatchEvent) -type eventHolder struct { - patternGroup *PatternGroup - event *watcher.Event + matchers []compiledPattern } type globalWatcher struct { - groups []*PatternGroup - watchers []*pattern - events chan eventHolder - stop chan struct{} + groups []*PatternGroup + fsw fswatcher.Watcher + done chan any + mu sync.Mutex + waitGroup sync.WaitGroup } func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGroup) error { @@ -59,27 +53,17 @@ func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGrou return nil } - if watcherIsActive.Load() { + if !watcherIsActive.CompareAndSwap(false, true) { return ErrAlreadyStarted } - watcherIsActive.Store(true) - globalCtx = ct + activeWatcher = &globalWatcher{groups: groups, done: make(chan any)} + globalContext = ct globalLogger = slogger - activeWatcher = &globalWatcher{groups: groups} - - for _, g := range groups { - if len(g.Patterns) == 0 { - continue - } - - for _, p := range g.Patterns { - activeWatcher.watchers = append(activeWatcher.watchers, &pattern{patternGroup: g, value: p}) - } - } - - if err := activeWatcher.startWatching(); err != nil { + if err := activeWatcher.start(); err != nil { + activeWatcher = nil + watcherIsActive.Store(false) return err } @@ -91,131 +75,159 @@ func DrainWatcher() { return } - watcherIsActive.Store(false) + if globalLogger.Enabled(globalContext, slog.LevelDebug) { + globalLogger.LogAttrs(globalContext, slog.LevelDebug, "stopping watcher") + } - if globalLogger.Enabled(globalCtx, slog.LevelDebug) { - globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "stopping watcher") + if activeWatcher != nil { + if activeWatcher.fsw != nil { + activeWatcher.fsw.Close() + } + if activeWatcher.done != nil { + close(activeWatcher.done) + } } - activeWatcher.stopWatching() - reloadWaitGroup.Wait() + activeWatcher.mu.Lock() + activeWatcher.mu.Unlock() + activeWatcher.waitGroup.Wait() activeWatcher = nil + watcherIsActive.Store(false) } -// TODO: how to test this? -func (p *pattern) retryWatching() { - failureMu.Lock() - defer failureMu.Unlock() - - if p.failureCount >= maxFailureCount { - if globalLogger.Enabled(globalCtx, slog.LevelWarn) { - globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "giving up watching", slog.String("pattern", p.value)) +func (pg *PatternGroup) compileMatchers() { + for _, pattern := range pg.Patterns { + root, regexStr, err := extractRootAndRegex(pattern) + if err != nil { + globalLogger.Error("invalid watcher pattern, unable to watch", "error", err, "pattern", pattern) + continue } - return + cp := compiledPattern{root: root} + re, err := regexp.Compile(regexStr) + if err != nil { + globalLogger.Error("invalid watcher pattern", "error", err, "pattern", pattern) + continue + } + cp.regex = re + pg.matchers = append(pg.matchers, cp) } +} - if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "watcher was closed prematurely, retrying...", slog.String("pattern", p.value)) +func (pg *PatternGroup) matchesPath(path string) bool { + for _, m := range pg.matchers { + if m.regex == nil { + if strings.HasPrefix(path, m.root) { + return true + } + continue + } + path = filepath.ToSlash(path) + if m.regex.MatchString(path) { + return true + } } - p.failureCount++ - - p.startSession() - - // reset the failure-count if the watcher hasn't reached max failures after 5 seconds - go func() { - time.Sleep(failureResetDuration) - - failureMu.Lock() - if p.failureCount < maxFailureCount { - p.failureCount = 0 - } - failureMu.Unlock() - }() + return false } -func (g *globalWatcher) startWatching() error { - g.events = make(chan eventHolder) - g.stop = make(chan struct{}) - - if err := g.parseFilePatterns(); err != nil { - return err +func (gw *globalWatcher) start() error { + opts := []fswatcher.WatcherOpt{ + fswatcher.WithSeverity(fswatcher.SeverityDebug), + fswatcher.WithCooldown(150 * time.Millisecond), } - for _, w := range g.watchers { - w.events = g.events - w.startSession() + watchedDirs := make(map[string]struct{}) + for _, pg := range gw.groups { + if len(pg.Patterns) == 0 { + continue + } + pg.compileMatchers() + for _, m := range pg.matchers { + if _, ok := watchedDirs[m.root]; ok { + continue + } + watchedDirs[m.root] = struct{}{} + opts = append(opts, fswatcher.WithPath(m.root)) + } } - go g.listenForFileEvents() - - return nil -} + if len(watchedDirs) == 0 { + return errors.New("no valid watch patterns") + } -func (g *globalWatcher) parseFilePatterns() error { - for _, w := range g.watchers { - if err := w.parse(); err != nil { - return err - } + fsw, err := fswatcher.New(opts...) + if err != nil { + globalLogger.Error("failed to create watcher", "error", err) + return err } + gw.fsw = fsw + gw.waitGroup.Add(1) + go gw.runWatcher() + go gw.listenForFileEvents() + return nil } -func (g *globalWatcher) stopWatching() { - close(g.stop) - for _, w := range g.watchers { - w.stop() +func (gw *globalWatcher) runWatcher() { + err := gw.fsw.Watch(globalContext) + if err != nil { + globalLogger.Error("failed to start watching", "error", err) } } -func (g *globalWatcher) listenForFileEvents() { - timer := time.NewTimer(debounceDuration) - timer.Stop() +func (gw *globalWatcher) listenForFileEvents() { + defer gw.waitGroup.Done() - eventsPerGroup := make(map[*PatternGroup][]*watcher.Event, len(g.groups)) + eventChan := gw.fsw.Events() - defer timer.Stop() for { + debouncedEvents := make([]fswatcher.WatchEvent, 0) select { - case <-g.stop: + case event := <-eventChan: + debouncedEvents = append(debouncedEvents, event) + case <-gw.done: return - case eh := <-g.events: - timer.Reset(debounceDuration) + } - eventsPerGroup[eh.patternGroup] = append(eventsPerGroup[eh.patternGroup], eh.event) - case <-timer.C: - timer.Stop() + DrainLoop: + for { + select { + case event := <-eventChan: + debouncedEvents = append(debouncedEvents, event) + default: + break DrainLoop + } + } - if globalLogger.Enabled(globalCtx, slog.LevelInfo) { - var events []*watcher.Event - for _, eventList := range eventsPerGroup { - events = append(events, eventList...) + changesDetected := false + for _, pg := range gw.groups { + if len(pg.matchers) == 0 { + continue + } + matched := make([]fswatcher.WatchEvent, 0) + for _, event := range debouncedEvents { + if pg.matchesPath(event.Path) { + matched = append(matched, event) } - - globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "filesystem changes detected", slog.Any("events", events)) } - - g.scheduleReload(eventsPerGroup) - eventsPerGroup = make(map[*PatternGroup][]*watcher.Event, len(g.groups)) + if len(matched) > 0 { + if !changesDetected && globalLogger.Enabled(globalContext, slog.LevelInfo) { + globalLogger.LogAttrs(globalContext, slog.LevelInfo, "filesystem changes detected", slog.Any("events", debouncedEvents)) + } + changesDetected = true + pg.triggerCallbacks(matched) + } } } } -func (g *globalWatcher) scheduleReload(eventsPerGroup map[*PatternGroup][]*watcher.Event) { - reloadWaitGroup.Add(1) - - // Call callbacks in order - for _, g := range g.groups { - if len(g.Patterns) == 0 { - g.Callback(nil) - } +func (pg *PatternGroup) triggerCallbacks(events []fswatcher.WatchEvent) { + activeWatcher.mu.Lock() + defer activeWatcher.mu.Unlock() - if e, ok := eventsPerGroup[g]; ok { - g.Callback(e) - } + if watcherIsActive.Load() { + pg.Callback(events) } - - reloadWaitGroup.Done() } diff --git a/phpinfo.go b/phpinfo.go index c62b41b922..dac78a1148 100644 --- a/phpinfo.go +++ b/phpinfo.go @@ -59,7 +59,6 @@ func collectPHPInfoEntries(buildInfo *debug.BuildInfo) (entries, modules []phpin modules = buildGoModuleEntries(buildInfo) moduleAliases := map[string]string{ "github.com/dunglas/mercure": "dunglas/mercure", - "github.com/e-dant/watcher": "e-dant/watcher", "github.com/dunglas/caddy-cbrotli": "dunglas/caddy-cbrotli", } for _, module := range modules { diff --git a/types_test.go b/types_test.go index 1efe1a90d9..900096fa91 100644 --- a/types_test.go +++ b/types_test.go @@ -295,7 +295,6 @@ func TestCollectPHPInfoEntries(t *testing.T) { t.Run(tt.name, func(t *testing.T) { entries, modules := collectPHPInfoEntries(&debug.BuildInfo{ GoVersion: "go1.26.0", - Main: debug.Module{Path: "github.com/e-dant/watcher", Version: "v3.0.0"}, Deps: []*debug.Module{ {Path: path, Version: "v1.2.3", Replace: tt.replace}, {Path: "example.com/other", Version: "v4.0.0"}, @@ -303,11 +302,11 @@ func TestCollectPHPInfoEntries(t *testing.T) { }, }) require.Equal(t, []phpinfoEntry{ - {"custom", "value"}, {"Go", "go1.26.0"}, {"e-dant/watcher", "v3.0.0"}, {key, tt.want}, + {"custom", "value"}, {"Go", "go1.26.0"}, {key, tt.want}, {"dunglas/caddy-cbrotli", "v1.0.0"}, }, entries) require.Equal(t, []phpinfoEntry{ - {"github.com/e-dant/watcher", "v3.0.0"}, {path, tt.want}, {"example.com/other", "v4.0.0"}, + {path, tt.want}, {"example.com/other", "v4.0.0"}, {"github.com/dunglas/caddy-cbrotli", "v1.0.0"}, }, modules) }) diff --git a/watcher.go b/watcher.go index 63864b9ef6..f4d54f9b5f 100644 --- a/watcher.go +++ b/watcher.go @@ -3,18 +3,14 @@ package frankenphp import ( - "sync/atomic" - "github.com/dunglas/frankenphp/internal/watcher" - watcherGo "github.com/e-dant/watcher/watcher-go" + "github.com/sgtdi/fswatcher" ) type hotReloadOpt struct { hotReload []*watcher.PatternGroup } -var restartWorkers atomic.Bool - // validateWatchers has nothing to refuse where the watcher is compiled in: // the patterns are only known to be good once the watcher took them func validateWatchers(*opt) error { @@ -26,7 +22,12 @@ func (o *hotReloadOpt) hasHotReload() bool { } func initWatchers(o *opt) error { - watchPatterns := make([]*watcher.PatternGroup, 0, len(o.hotReload)) + allPatterns := make([]*watcher.PatternGroup, 0, len(o.hotReload)) + workerPattern := &watcher.PatternGroup{ + Callback: func(_ []fswatcher.WatchEvent) { + RestartWorkers() + }, + } for _, o := range o.workers { if len(o.watch) == 0 { @@ -34,22 +35,14 @@ func initWatchers(o *opt) error { } watcherIsEnabled = true - watchPatterns = append(watchPatterns, &watcher.PatternGroup{Patterns: o.watch, Callback: func(_ []*watcherGo.Event) { - restartWorkers.Store(true) - }}) + workerPattern.Patterns = append(workerPattern.Patterns, o.watch...) } if watcherIsEnabled { - watchPatterns = append(watchPatterns, &watcher.PatternGroup{ - Callback: func(_ []*watcherGo.Event) { - if restartWorkers.Swap(false) { - RestartWorkers() - } - }, - }) + allPatterns = append(allPatterns, workerPattern) } - return watcher.InitWatcher(globalCtx, globalLogger, append(watchPatterns, o.hotReload...)) + return watcher.InitWatcher(globalCtx, globalLogger, append(allPatterns, o.hotReload...)) } func drainWatchers() {