feat: restart workers when on source changes (#1013)

* Adds filesystem watcher with tests.

* Refactoring.

* Formatting.

* Formatting.

* Switches to absolute path in tests.

* Fixes race condition from merge conflict.

* Fixes race condition.

* Fixes tests.

* Fixes markdown lint errors.

* Switches back to absolute paths.

* Reverts back to relative file paths.

* Fixes golangci-lint issues.

* Uses github.com/dunglas/go-fswatch instead.

* Stops watcher before stopping workers.

* Updates docs.

* Avoids segfault in tests.

* Fixes watcher segmentation violations on shutdown.

* Adjusts watcher latencies and tests.

* Adds fswatch to dockerfiles

* Fixes fswatch in alpine.

* Fixes segfault (this time for real).

* Allows queueing new reload if file changes while workers are reloading.

* Makes tests more consistent.

* Prevents the watcher from getting stuck if there is an error in the worker file itself.

* Reverts changing the image.

* Puts fswatch version into docker-bake.hcl.

* Asserts instead of panicking.

* Adds notice

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update dev.Dockerfile

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update Dockerfile

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update Dockerfile

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update alpine.Dockerfile

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update alpine.Dockerfile

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update dev-alpine.Dockerfile

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update dev-alpine.Dockerfile

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update dev.Dockerfile

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update docs/config.md

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Runs fswatch version.

* Removes .json.

* Replaces ms with s.

* Resets the channel after closing it.

* Update watcher_options.go

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update watcher_test.go

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Asserts no error instead.

* Fixes a race condition where events are fired after frankenphp has stopped.

* Updates docs.

* Update watcher_options_test.go

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Allows queuing events while watchers are reloading.

* go fmt

* Refactors stopping and draining logic.

* Allows extended watcher configuration with dirs, recursion, symlinks, case-sensitivity, latency, monitor types and regex.

* Updates docs.

* Adds TODOS.

* go fmt.

* Fixes linting errors.

* Also allows wildcards in the longform and adjusts docs.

* Adds debug log.

* Fixes the watcher short form.

* Refactors sessions and options into a struct.

* Fixes an overflow in the 'workersReadyWG' on unexpected terminations.

* Properly logs errors coming from session.Start().

* go fmt.

* Adds --nocache.

* Fixes lint issue.

* Refactors and resolves race condition on worker reload.

* Implements debouncing with a timer as suggested by @withinboredom.

* Starts watcher even if no workers are defined.

* Updates docs with file limit warning.

* Adds watch config unit tests.

* Adjusts debounce timings.

* go fmt.

* Adds fswatch to static builder (test).

* Adds a short grace period between stopping and destroying the watcher sessions.

* Adds caddy test.

* Adjusts sleep time.

* Swap to edant/watcher.

* Fixes watch options and tests.

* go fmt.

* Adds TODO.

* Installs edant/watcher in the bookworm image.

* Fixes linting.

* Refactors the watcher into its own module.

* Adjusts naming.

* ADocker image adjustments and refactoring.

* Testing installation methods.

* Installs via gcc instead.

* Fixes pointer formats.

* Fixes lint issues.

* Fixes arm alpine and updates docs.

* Clang format.

* Fixes dirs.

* Adds watcher version arg.

* Uses static lib version.

* Adds watcher to tests and sanitizers.

* Uses sudo for copying the shared lib.

* Removes unnused func.

* Refactoring.

* Update .github/workflows/sanitizers.yaml

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Adds fpic.

* Fixes linting.

* Skips tests in msan.

* Resets op_cache in every worker thread after termination

* Review fixes part 1.

* Test: installing libstc++ instead of gcc.

* Test: using msan ignorelist.

* Test: using msan ignorelist.

* Test: using msan ignorelist.

* Allows '/**/' for global recursion and '**/' for relative recursion.

* Reverts using the ignorelist.

* Calls opcache directly.

* Adds --watch to php-server command

* Properly free CStrings.

* Sorts alphabetically and uses curl instead of git.

* Labeling and formatting.

* Update .github/workflows/sanitizers.yaml

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update .github/workflows/sanitizers.yaml

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update .github/workflows/tests.yaml

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update .github/workflows/tests.yaml

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update caddy/caddy.go

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update docs/config.md

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update frankenphp_with_watcher_test.go

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update watcher/watcher.h

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update frankenphp.c

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update watcher/watcher.go

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update docs/config.md

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update frankenphp_with_watcher_test.go

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update testdata/files/.gitignore

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update watcher/watcher-c.h

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Update watcher/watcher.c

Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>

* Fixes test and Dockerfile.

* Fixes Dockerfiles.

* Resets go versions.

* Replaces unsafe.pointer with uintptr_t

* Prevents worker channels from being destroyed on reload.

* Minimizes the public api by only passing a []string.

* Adds support for directory patterns and multiple '**' globs.

* Adjusts label.

* go fmt.

* go mod tidy.

* Fixes merge conflict.

* Refactoring and formatting.

* Cleans up unused vars and functions.

* Allows dirs with a dot.

* Makes test nicer.

* Add dir tests.

* Moves the watch directive inside the worker directive.

* Adds debug log on special events.

* Removes line about symlinks.

* Hints at multiple possible --watch flags.

* Adds ./**/*.php as default watch configuration.

* Changes error to a warning.

* Changes the default to './**/*.{php,yaml,yml,twig,env}' and supports the {bracket} pattern.

* Fixes linting.

* Fixes merge conflict and adjust values.

* Adjusts values.

---------

Co-authored-by: a.stecher <a.stecher@sportradar.com>
Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>
This commit is contained in:
Alexander Stecher
2024-10-07 13:17:24 +02:00
committed by GitHub
parent aa585f7da0
commit 8d9b6e755b
31 changed files with 1018 additions and 42 deletions

View File

@@ -23,13 +23,14 @@ jobs:
matrix:
sanitizer: ['asan', 'msan']
env:
CFLAGS: -g -O0 -fsanitize=${{ matrix.sanitizer == 'asan' && 'address' || 'memory' }} -DZEND_TRACK_ARENA_ALLOC
CFLAGS: -g -O0 -fsanitize=${{ matrix.sanitizer == 'asan' && 'address' || 'memory' }} -DZEND_TRACK_ARENA_ALLOC
LDFLAGS: -fsanitize=${{ matrix.sanitizer == 'asan' && 'address' || 'memory' }}
CC: clang
CXX: clang++
USE_ZEND_ALLOC: 0
LIBRARY_PATH: ${{ github.workspace }}/php/target/lib
LD_LIBRARY_PATH: ${{ github.workspace }}/php/target/lib
EDANT_WATCHER_VERSION: next
steps:
-
name: Remove local PHP
@@ -95,6 +96,20 @@ jobs:
-
name: Add PHP to the PATH
run: echo "$(pwd)/php/target/bin" >> "$GITHUB_PATH"
-
uses: actions/checkout@v4
name: Checkout watcher
with:
repository: e-dant/watcher
ref: ${{ env.EDANT_WATCHER_VERSION }}
path: 'edant/watcher'
-
name: Compile edant/watcher
run: |
cd edant/watcher/watcher-c/
clang -o libwatcher.so ./src/watcher-c.cpp -I ./include -I ../include -std=c++17 -Wall -Wextra -fPIC -shared ${{ matrix.sanitizer == 'msan' && '-fsanitize=memory -fno-omit-frame-pointer -fno-optimize-sibling-calls' || '' }}
sudo cp libwatcher.so /usr/local/lib/libwatcher.so
sudo ldconfig
-
name: Set Set CGO flags
run: |

View File

@@ -23,6 +23,7 @@ jobs:
env:
GOEXPERIMENT: cgocheck2
GOMAXPROCS: 10
EDANT_WATCHER_VERSION: next
steps:
-
uses: actions/checkout@v4
@@ -43,6 +44,20 @@ jobs:
env:
phpts: ts
debug: true
-
uses: actions/checkout@v4
name: Checkout watcher
with:
repository: e-dant/watcher
ref: ${{ env.EDANT_WATCHER_VERSION }}
path: 'edant/watcher'
-
name: Compile edant/watcher
run: |
cd edant/watcher/watcher-c/
gcc -o libwatcher.so ./src/watcher-c.cpp -I ./include -I ../include -std=c++17 -O3 -Wall -Wextra -fPIC -shared
sudo cp libwatcher.so /usr/local/lib/libwatcher.so
sudo ldconfig
-
name: Set CGO flags
run: |

View File

@@ -85,6 +85,16 @@ COPY --link *.* ./
COPY --link caddy caddy
COPY --link internal internal
COPY --link testdata testdata
COPY --link watcher watcher
# install edant/watcher (necessary for file watching)
ARG EDANT_WATCHER_VERSION=next
WORKDIR /usr/local/src/watcher
RUN curl -L https://github.com/e-dant/watcher/archive/refs/heads/$EDANT_WATCHER_VERSION.tar.gz | tar xz
WORKDIR /usr/local/src/watcher/watcher-$EDANT_WATCHER_VERSION/watcher-c
RUN gcc -o libwatcher.so ./src/watcher-c.cpp -I ./include -I ../include -std=c++17 -O3 -Wall -Wextra -fPIC -shared && \
cp libwatcher.so /usr/local/lib/libwatcher.so && \
ldconfig /usr/local/lib
# See https://github.com/docker-library/php/blob/master/8.3/bookworm/zts/Dockerfile#L57-L59 for PHP values
ENV CGO_CFLAGS="-DFRANKENPHP_VERSION=$FRANKENPHP_VERSION $PHP_CFLAGS"
@@ -104,6 +114,13 @@ 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
COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp
RUN setcap cap_net_bind_service=+ep /usr/local/bin/frankenphp && \
frankenphp version

View File

@@ -58,21 +58,24 @@ ENV PATH=/usr/local/go/bin:$PATH
RUN apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
argon2-dev \
# Needed for the custom Go build
bash \
brotli-dev \
coreutils \
curl-dev \
# Needed for the custom Go build
git \
gnu-libiconv-dev \
libsodium-dev \
# Needed for the file watcher
libstdc++ \
libxml2-dev \
linux-headers \
oniguruma-dev \
openssl-dev \
readline-dev \
sqlite-dev \
upx \
# Needed for the custom Go build
git \
bash
upx
# FIXME: temporary workaround for https://github.com/golang/go/issues/68285
WORKDIR /
@@ -103,6 +106,16 @@ COPY --link *.* ./
COPY --link caddy caddy
COPY --link internal internal
COPY --link testdata testdata
COPY --link watcher watcher
# install edant/watcher (necessary for file watching)
ARG EDANT_WATCHER_VERSION=next
WORKDIR /usr/local/src/watcher
RUN curl -L https://github.com/e-dant/watcher/archive/refs/heads/$EDANT_WATCHER_VERSION.tar.gz | tar xz
WORKDIR /usr/local/src/watcher/watcher-$EDANT_WATCHER_VERSION/watcher-c
RUN gcc -o libwatcher.so ./src/watcher-c.cpp -I ./include -I ../include -std=c++17 -O3 -Wall -Wextra -fPIC -shared && \
cp libwatcher.so /usr/local/lib/libwatcher.so && \
ldconfig /usr/local/lib
# See https://github.com/docker-library/php/blob/master/8.3/alpine3.20/zts/Dockerfile#L53-L55
ENV CGO_CFLAGS="-DFRANKENPHP_VERSION=$FRANKENPHP_VERSION $PHP_CFLAGS"
@@ -122,6 +135,11 @@ 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

View File

@@ -142,6 +142,16 @@ if [ "${os}" = "linux" ]; then
CGO_LDFLAGS="${CGO_LDFLAGS} -lstdc++"
fi
fi
# install edant/watcher for file watching (static version)
git clone --branch="${EDANT_WATCHER_VERSION:-next}" https://github.com/e-dant/watcher watcher
cd watcher/watcher-c
gcc -c -o libwatcher.o ./src/watcher-c.cpp -I ./include -I ../include -std=c++17 -Wall -Wextra -fPIC
ar rcs libwatcher.a libwatcher.o
cp libwatcher.a "../../buildroot/lib/libwatcher.a"
cd ../../
CGO_LDFLAGS="${CGO_LDFLAGS} -lstdc++ ${PWD}/buildroot/lib/libwatcher.a"
export CGO_LDFLAGS
LIBPHP_VERSION="$(./buildroot/bin/php-config --version)"

View File

@@ -62,6 +62,8 @@ type workerConfig struct {
Num int `json:"num,omitempty"`
// Env sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables.
Env map[string]string `json:"env,omitempty"`
// Directories to watch for file changes
Watch []string `json:"watch,omitempty"`
}
type FrankenPHPApp struct {
@@ -85,7 +87,7 @@ func (f *FrankenPHPApp) Start() error {
opts := []frankenphp.Option{frankenphp.WithNumThreads(f.NumThreads), frankenphp.WithLogger(logger), frankenphp.WithMetrics(metrics)}
for _, w := range f.Workers {
opts = append(opts, frankenphp.WithWorkers(repl.ReplaceKnown(w.FileName, ""), w.Num, w.Env))
opts = append(opts, frankenphp.WithWorkers(repl.ReplaceKnown(w.FileName, ""), w.Num, w.Env, w.Watch))
}
_, loaded, err := phpInterpreter.LoadOrNew(mainPHPInterpreterKey, func() (caddy.Destructor, error) {
@@ -134,7 +136,6 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
f.NumThreads = v
case "worker":
wc := workerConfig{}
if d.NextArg() {
@@ -178,6 +179,13 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
wc.Env = make(map[string]string)
}
wc.Env[args[0]] = args[1]
case "watch":
if !d.NextArg() {
// the default if the watch directory is left empty:
wc.Watch = append(wc.Watch, "./**/*.{php,yaml,yml,twig,env}")
} else {
wc.Watch = append(wc.Watch, d.Val())
}
}
if wc.FileName == "" {

View File

@@ -574,3 +574,31 @@ func TestAutoWorkerConfig(t *testing.T) {
"frankenphp_testdata_index_php_ready_workers",
))
}
func TestWorkerWithInactiveWatcher(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port 9080
frankenphp {
worker {
file ../testdata/worker-with-watcher.php
num 1
watch ./**/*.php
}
}
}
localhost:9080 {
root ../testdata
rewrite worker-with-watcher.php
php
}
`, "caddyfile")
tester.AssertGetResponse("http://localhost:9080", http.StatusOK, "requests:1")
tester.AssertGetResponse("http://localhost:9080", http.StatusOK, "requests:2")
}

View File

@@ -29,7 +29,7 @@ import (
func init() {
caddycmd.RegisterCommand(caddycmd.Command{
Name: "php-server",
Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--worker /path/to/worker.php<,nb-workers>] [--access-log] [--debug] [--no-compress] [--mercure]",
Usage: "[--domain <example.com>] [--root <path>] [--listen <addr>] [--worker /path/to/worker.php<,nb-workers>] [--watch <paths...>] [--access-log] [--debug] [--no-compress] [--mercure]",
Short: "Spins up a production-ready PHP server",
Long: `
A simple but production-ready PHP server. Useful for quick deployments,
@@ -48,6 +48,7 @@ For more advanced use cases, see https://github.com/dunglas/frankenphp/blob/main
cmd.Flags().StringP("root", "r", "", "The path to the root of the site")
cmd.Flags().StringP("listen", "l", "", "The address to which to bind the listener")
cmd.Flags().StringArrayP("worker", "w", []string{}, "Worker script")
cmd.Flags().StringArrayP("watch", "", []string{}, "Directory to watch for file changes")
cmd.Flags().BoolP("access-log", "a", false, "Enable the access log")
cmd.Flags().BoolP("debug", "v", false, "Enable verbose debug logs")
cmd.Flags().BoolP("mercure", "m", false, "Enable the built-in Mercure.rocks hub")
@@ -73,6 +74,10 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) {
if err != nil {
panic(err)
}
watch, err := fs.GetStringArray("watch")
if err != nil {
panic(err)
}
var workersOption []workerConfig
if len(workers) != 0 {
@@ -90,6 +95,7 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) {
workersOption = append(workersOption, workerConfig{FileName: parts[0], Num: num})
}
workersOption[0].Watch = watch
}
if frankenphp.EmbeddedAppPath != "" {

View File

@@ -15,6 +15,8 @@ ENV PHPIZE_DEPS="\
pkgconfig \
re2c"
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
RUN apk add --no-cache \
$PHPIZE_DEPS \
argon2-dev \
@@ -29,6 +31,9 @@ RUN apk add --no-cache \
zlib-dev \
bison \
nss-tools \
# file watcher
libstdc++ \
linux-headers \
# Dev tools \
git \
clang \
@@ -58,6 +63,15 @@ RUN git clone --branch=PHP-8.3 https://github.com/php/php-src.git . && \
echo "opcache.enable=1" >> /usr/local/lib/php.ini && \
php --version
# install edant/watcher (necessary for file watching)
ARG EDANT_WATCHER_VERSION=next
WORKDIR /usr/local/src/watcher
RUN git clone --branch=$EDANT_WATCHER_VERSION https://github.com/e-dant/watcher .
WORKDIR /usr/local/src/watcher/watcher-c
RUN gcc -o libwatcher.so ./src/watcher-c.cpp -I ./include -I ../include -std=c++17 -O3 -Wall -Wextra -fPIC -shared && \
cp libwatcher.so /usr/local/lib/libwatcher.so && \
ldconfig /usr/local/lib
WORKDIR /go/src/app
COPY . .

View File

@@ -15,6 +15,8 @@ ENV PHPIZE_DEPS="\
pkg-config \
re2c"
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# hadolint ignore=DL3009
RUN apt-get update && \
apt-get -y --no-install-recommends install \
@@ -63,6 +65,15 @@ RUN git clone --branch=PHP-8.3 https://github.com/php/php-src.git . && \
echo "opcache.enable=1" >> /usr/local/lib/php.ini && \
php --version
# install edant/watcher (necessary for file watching)
ARG EDANT_WATCHER_VERSION=next
WORKDIR /usr/local/src/watcher
RUN git clone --branch=$EDANT_WATCHER_VERSION https://github.com/e-dant/watcher .
WORKDIR /usr/local/src/watcher/watcher-c
RUN gcc -o libwatcher.so ./src/watcher-c.cpp -I ./include -I ../include -std=c++17 -O3 -Wall -Wextra -fPIC -shared && \
cp libwatcher.so /usr/local/lib/libwatcher.so && \
ldconfig /usr/local/lib
WORKDIR /go/src/app
COPY . .

View File

@@ -14,6 +14,10 @@ variable "GO_VERSION" {
default = "1.22"
}
variable EDANT_WATCHER_VERSION {
default = "next"
}
variable "SHA" {}
variable "LATEST" {
@@ -115,6 +119,7 @@ target "default" {
}
args = {
FRANKENPHP_VERSION = VERSION
EDANT_WATCHER_VERSION = EDANT_WATCHER_VERSION
}
}
@@ -140,6 +145,7 @@ target "static-builder" {
}
args = {
FRANKENPHP_VERSION = VERSION
EDANT_WATCHER_VERSION = EDANT_WATCHER_VERSION
}
secret = ["id=github-token,env=GITHUB_TOKEN"]
}

View File

@@ -51,6 +51,7 @@ Optionally, the number of threads to create and [worker scripts](worker.md) to s
file <path> # Sets the path to the worker script.
num <num> # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs.
env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables.
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
}
}
}
@@ -131,6 +132,51 @@ php_server [<matcher>] {
}
```
### Watching for File Changes
Since workers only boot your application once and keep it in memory, any changes
to your PHP files will not be reflected immediately.
Workers can instead be restarted on file changes via the `watch` directive.
This is useful for development environments.
```caddyfile
{
frankenphp {
worker {
file /path/to/app/public/worker.php
watch
}
}
}
```
If the `watch` directory is not specified, it will fall back to `./**/*.{php,yaml,yml,twig,env}`,
which watches all `.php`, `.yaml`, `.yml`, `.twig` and `.env` files in the directory and subdirectories
where the FrankenPHP process was started. You can instead also specify one or more directories via a
[shell filename pattern](https://pkg.go.dev/path/filepath#Match):
```caddyfile
{
frankenphp {
worker {
file /path/to/app/public/worker.php
watch /path/to/app # watches all files in all subdirectories of /path/to/app
watch /path/to/app/*.php # watches files ending in .php in /path/to/app
watch /path/to/app/**/*.php # watches PHP files in /path/to/app and subdirectories
watch /path/to/app/**/*.{php,twig} # watches PHP and Twig files in /path/to/app and subdirectories
}
}
}
```
* The `**` pattern signifies recursive watching
* Directories can also be relative (to where the FrankenPHP process is started from)
* 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).
### Full Duplex (HTTP/1)
When using HTTP/1.x, it may be desirable to enable full-duplex mode to allow writing a response before the entire body
@@ -149,7 +195,7 @@ This is an opt-in configuration that needs to be added to the global options in
> [!CAUTION]
>
> Enabling this option may cause old HTTP/1.x clients that don't support full-duplex to deadlock.
This can also be configured using the `CADDY_GLOBAL_OPTIONS` environment config:
> This can also be configured using the `CADDY_GLOBAL_OPTIONS` environment config:
```sh
CADDY_GLOBAL_OPTIONS="servers { enable_full_duplex }"

View File

@@ -25,9 +25,16 @@ Use the `--worker` option of the `php-server` command to serve the content of th
./frankenphp php-server --worker /path/to/your/worker/script.php
```
If your PHP app is [embeded in the binary](embed.md), you can add a custom `Caddyfile` in the root directory of the app.
If your PHP app is [embedded in the binary](embed.md), you can add a custom `Caddyfile` in the root directory of the app.
It will be used automatically.
It's also possible to [restart the worker on file changes](config.md#watching-for-file-changes) with the `--watch` option.
The following command will trigger a restart if any file ending in `.php` in the `/path/to/your/app/` directory or subdirectories is modified:
```console
./frankenphp php-server --worker /path/to/your/worker/script.php --watch "/path/to/your/app/**/*.php"
```
## Symfony Runtime
The worker mode of FrankenPHP is supported by the [Symfony Runtime Component](https://symfony.com/doc/current/components/runtime.html).

View File

@@ -1056,3 +1056,22 @@ int frankenphp_execute_script_cli(char *script, int argc, char **argv) {
return (intptr_t)exit_status;
}
int frankenphp_execute_php_function(const char *php_function) {
zval retval = {0};
zend_fcall_info fci = {0};
zend_fcall_info_cache fci_cache = {0};
zend_string *func_name =
zend_string_init(php_function, strlen(php_function), 0);
ZVAL_STR(&fci.function_name, func_name);
fci.size = sizeof fci;
fci.retval = &retval;
int success = 0;
zend_try { success = zend_call_function(&fci, &fci_cache) == SUCCESS; }
zend_end_try();
zend_string_release(func_name);
return success;
}

View File

@@ -347,6 +347,10 @@ func Init(options ...Option) error {
return err
}
if err := restartWorkersOnFileChanges(opt.workers); err != nil {
return err
}
if c := logger.Check(zapcore.InfoLevel, "FrankenPHP started 🐘"); c != nil {
c.Write(zap.String("php_version", Version().Version), zap.Int("num_threads", opt.numThreads))
}
@@ -361,15 +365,11 @@ func Init(options ...Option) error {
// Shutdown stops the workers and the PHP runtime.
func Shutdown() {
stopWorkers()
close(done)
shutdownWG.Wait()
drainWorkers()
drainThreads()
metrics.Shutdown()
requestChan = nil
// Always reset the WaitGroup to ensure we're in a clean state
workersReadyWG = sync.WaitGroup{}
// Remove the installed app
if EmbeddedAppPath != "" {
os.RemoveAll(EmbeddedAppPath)
@@ -383,6 +383,11 @@ func go_shutdown() {
shutdownWG.Done()
}
func drainThreads() {
close(done)
shutdownWG.Wait()
}
func getLogger() *zap.Logger {
loggerMu.RLock()
defer loggerMu.RUnlock()
@@ -862,3 +867,20 @@ func freeArgs(argv []*C.char) {
C.free(unsafe.Pointer(arg))
}
}
func executePHPFunction(functionName string) {
cFunctionName := C.CString(functionName)
defer C.free(unsafe.Pointer(cFunctionName))
success := C.frankenphp_execute_php_function(cFunctionName)
if success == 1 {
if c := logger.Check(zapcore.DebugLevel, "php function call successful"); c != nil {
c.Write(zap.String("function", functionName))
}
} else {
if c := logger.Check(zapcore.ErrorLevel, "php function call failed"); c != nil {
c.Write(zap.String("function", functionName))
}
}
}

View File

@@ -56,4 +56,6 @@ void frankenphp_register_bulk_variables(go_string known_variables[27],
int frankenphp_execute_script_cli(char *script, int argc, char **argv);
int frankenphp_execute_php_function(const char *php_function);
#endif

View File

@@ -35,6 +35,7 @@ import (
type testOptions struct {
workerScript string
watch []string
nbWorkers int
env map[string]string
nbParrallelRequests int
@@ -60,7 +61,7 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *
initOpts := []frankenphp.Option{frankenphp.WithLogger(opts.logger)}
if opts.workerScript != "" {
initOpts = append(initOpts, frankenphp.WithWorkers(testDataDir+opts.workerScript, opts.nbWorkers, opts.env))
initOpts = append(initOpts, frankenphp.WithWorkers(testDataDir+opts.workerScript, opts.nbWorkers, opts.env, opts.watch))
}
initOpts = append(initOpts, opts.initOpts...)

View File

@@ -0,0 +1,94 @@
package frankenphp_test
import (
"github.com/stretchr/testify/assert"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// we have to wait a few milliseconds for the watcher debounce to take effect
const pollingTime = 250
// in tests checking for no reload: we will poll 3x250ms = 0.75s
const minTimesToPollForChanges = 3
// in tests checking for a reload: we will poll a maximum of 60x250ms = 15s
const maxTimesToPollForChanges = 60
func TestWorkersShouldReloadOnMatchingPattern(t *testing.T) {
if isRunningInMsanMode() {
t.Skip("Skipping watcher tests in memory sanitizer mode")
return
}
watch := []string{"./testdata/**/*.txt"}
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
requestBodyHasReset := pollForWorkerReset(t, handler, maxTimesToPollForChanges)
assert.True(t, requestBodyHasReset)
}, &testOptions{nbParrallelRequests: 1, nbWorkers: 1, workerScript: "worker-with-watcher.php", watch: watch})
}
func TestWorkersShouldNotReloadOnExcludingPattern(t *testing.T) {
if isRunningInMsanMode() {
t.Skip("Skipping watcher tests in memory sanitizer mode")
return
}
watch := []string{"./testdata/**/*.php"}
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
requestBodyHasReset := pollForWorkerReset(t, handler, minTimesToPollForChanges)
assert.False(t, requestBodyHasReset)
}, &testOptions{nbParrallelRequests: 1, nbWorkers: 1, workerScript: "worker-with-watcher.php", watch: watch})
}
func fetchBody(method string, url string, handler func(http.ResponseWriter, *http.Request)) string {
req := httptest.NewRequest(method, url, nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
body, _ := io.ReadAll(resp.Body)
return string(body)
}
func isRunningInMsanMode() bool {
cflags := os.Getenv("CFLAGS")
return strings.Contains(cflags, "-fsanitize=memory")
}
func pollForWorkerReset(t *testing.T, handler func(http.ResponseWriter, *http.Request), limit int) bool {
// first we make an initial request to start the request counter
body := fetchBody("GET", "http://example.com/worker-with-watcher.php", handler)
assert.Equal(t, "requests:1", body)
// now we spam file updates and check if the request counter resets
for i := 0; i < limit; i++ {
updateTestFile("./testdata/files/test.txt", "updated", t)
time.Sleep(pollingTime * time.Millisecond)
body := fetchBody("GET", "http://example.com/worker-with-watcher.php", handler)
if body == "requests:1" {
return true
}
}
return false
}
func updateTestFile(fileName string, content string, t *testing.T) {
absFileName, err := filepath.Abs(fileName)
assert.NoError(t, err)
dirName := filepath.Dir(absFileName)
if _, err := os.Stat(dirName); os.IsNotExist(err) {
err = os.MkdirAll(dirName, 0700)
assert.NoError(t, err)
}
bytes := []byte(content)
err = os.WriteFile(absFileName, bytes, 0644)
assert.NoError(t, err)
}

8
go.sum
View File

@@ -2,23 +2,20 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/gammazero/deque v0.2.1 h1:qSdsbG6pgp6nL7A0+K/B7s12mcCY/5l5SIUpMOl+dC0=
github.com/gammazero/deque v0.2.1/go.mod h1:LFroj8x4cMYCukHJDbxFCkT+r9AndaJnFMuZDV34tuU=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/maypok86/otter v1.2.2 h1:jJi0y8ruR/ZcKmJ4FbQj3QQTqKwV+LNrSOo2S1zbF5M=
github.com/maypok86/otter v1.2.2/go.mod h1:mKLfoI7v1HOmQMwFgX4QkRk23mX6ge3RDvjdHOWG4R4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
@@ -29,7 +26,6 @@ github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSz
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=

View File

@@ -21,6 +21,7 @@ type workerOpt struct {
fileName string
num int
env PreparedEnv
watch []string
}
// WithNumThreads configures the number of PHP threads to start.
@@ -41,9 +42,9 @@ func WithMetrics(m Metrics) Option {
}
// WithWorkers configures the PHP workers to start.
func WithWorkers(fileName string, num int, env map[string]string) Option {
func WithWorkers(fileName string, num int, env map[string]string, watch []string) Option {
return func(o *opt) error {
o.workers = append(o.workers, workerOpt{fileName, num, PrepareEnv(env)})
o.workers = append(o.workers, workerOpt{fileName, num, PrepareEnv(env), watch})
return nil
}

View File

@@ -12,6 +12,9 @@ ENV FRANKENPHP_VERSION=${FRANKENPHP_VERSION}
ARG PHP_VERSION=''
ENV PHP_VERSION=${PHP_VERSION}
ARG EDANT_WATCHER_VERSION=''
ENV EDANT_WATCHER_VERSION=${EDANT_WATCHER_VERSION}
ARG PHP_EXTENSIONS=''
ARG PHP_EXTENSION_LIBS=''
ARG CLEAN=''
@@ -103,6 +106,7 @@ RUN go mod graph | awk '{if ($1 !~ "@") print $2}' | xargs go get
WORKDIR /go/src/app
COPY *.* ./
COPY caddy caddy
COPY watcher watcher
RUN --mount=type=secret,id=github-token GITHUB_TOKEN=$(cat /run/secrets/github-token) ./build-static.sh && \
rm -Rf dist/static-php-cli/source/*

1
testdata/files/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.txt

11
testdata/worker-with-watcher.php vendored Normal file
View File

@@ -0,0 +1,11 @@
<?php
$numberOfRequests = 0;
$printNumberOfRequests = function () use (&$numberOfRequests) {
$numberOfRequests++;
echo "requests:$numberOfRequests";
};
while (frankenphp_handle_request($printNumberOfRequests)) {
}

166
watcher/watch_pattern.go Normal file
View File

@@ -0,0 +1,166 @@
package watcher
import (
"go.uber.org/zap"
"path/filepath"
"strings"
)
type watchPattern struct {
dir string
patterns []string
trigger chan struct{}
}
func parseFilePatterns(filePatterns []string) ([]*watchPattern, error) {
watchPatterns := make([]*watchPattern, 0, len(filePatterns))
for _, filePattern := range filePatterns {
watchPattern, err := parseFilePattern(filePattern)
if err != nil {
return nil, err
}
watchPatterns = append(watchPatterns, watchPattern)
}
return watchPatterns, nil
}
// this method prepares the watchPattern struct for a single file pattern (aka /path/*pattern)
// TODO: using '/' is more efficient than filepath functions, but does not work on windows
func parseFilePattern(filePattern string) (*watchPattern, error) {
w := &watchPattern{}
// first we clean the pattern
absPattern, err := filepath.Abs(filePattern)
if err != nil {
return nil, err
}
w.dir = absPattern
// then we split the pattern to determine where the directory ends and the pattern starts
splitPattern := strings.Split(absPattern, "/")
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:]...)
w.dir = filepath.Join(splitPattern[:i]...)
break
}
}
// now we split the pattern according to the recursive '**' syntax
w.patterns = strings.Split(patternWithoutDir, "**")
for i, pattern := range w.patterns {
w.patterns[i] = strings.Trim(pattern, "/")
}
// finally, we remove the trailing slash and add leading slash
w.dir = "/" + strings.Trim(w.dir, "/")
return w, nil
}
func (watchPattern *watchPattern) allowReload(fileName string, eventType int, pathType int) bool {
if !isValidEventType(eventType) || !isValidPathType(pathType, fileName) {
return false
}
return isValidPattern(fileName, watchPattern.dir, watchPattern.patterns)
}
// 0:rename,1:modify,2:create,3:destroy,4:owner,5:other,
func isValidEventType(eventType int) bool {
return eventType <= 3
}
// 0:dir,1:file,2:hard_link,3:sym_link,4:watcher,5:other,
func isValidPathType(pathType int, fileName string) bool {
if pathType == 4 {
logger.Debug("special edant/watcher event", zap.String("fileName", fileName))
}
return pathType <= 2
}
func isValidPattern(fileName string, dir string, patterns []string) bool {
// first we remove the dir from the pattern
if !strings.HasPrefix(fileName, dir) {
return false
}
fileNameWithoutDir := strings.TrimLeft(fileName, dir+"/")
// if the pattern has size 1 we can match it directly against the filename
if len(patterns) == 1 {
return matchBracketPattern(patterns[0], fileNameWithoutDir)
}
return matchPatterns(patterns, fileNameWithoutDir)
}
func matchPatterns(patterns []string, fileName string) bool {
partsToMatch := strings.Split(fileName, "/")
cursor := 0
// if there are multiple patterns due to '**' we need to match them individually
for i, pattern := range patterns {
patternSize := strings.Count(pattern, "/") + 1
// if we are at the last pattern we will start matching from the end of the filename
if i == len(patterns)-1 {
cursor = len(partsToMatch) - patternSize
}
// the cursor will move through the fileName until the pattern matches
for j := cursor; j < len(partsToMatch); j++ {
cursor = j
subPattern := strings.Join(partsToMatch[j:j+patternSize], "/")
if matchBracketPattern(pattern, subPattern) {
cursor = j + patternSize - 1
break
}
if cursor > len(partsToMatch)-patternSize-1 {
return false
}
}
}
return true
}
// we also check for the following bracket syntax: /path/*.{php,twig,yaml}
func matchBracketPattern(pattern string, fileName string) bool {
openingBracket := strings.Index(pattern, "{")
closingBracket := strings.Index(pattern, "}")
// if there are no brackets we can match regularly
if openingBracket == -1 || closingBracket == -1 {
return matchPattern(pattern, fileName)
}
beforeTheBrackets := pattern[:openingBracket]
betweenTheBrackets := pattern[openingBracket+1 : closingBracket]
afterTheBrackets := pattern[closingBracket+1:]
// all bracket entries are checked individually, only one needs to match
// *.{php,twig,yaml} -> *.php, *.twig, *.yaml
for _, pattern := range strings.Split(betweenTheBrackets, ",") {
if matchPattern(beforeTheBrackets+pattern+afterTheBrackets, fileName) {
return true
}
}
return false
}
func matchPattern(pattern string, fileName string) bool {
if pattern == "" {
return true
}
patternMatches, err := filepath.Match(pattern, fileName)
if err != nil {
logger.Error("failed to match filename", zap.String("file", fileName), zap.Error(err))
return false
}
return patternMatches
}

View File

@@ -0,0 +1,162 @@
package watcher
import (
"github.com/stretchr/testify/assert"
"path/filepath"
"testing"
)
func TestDisallowOnEventTypeBiggerThan3(t *testing.T) {
const fileName = "/some/path/watch-me.php"
const eventType = 4
watchPattern, err := parseFilePattern("/some/path")
assert.NoError(t, err)
assert.False(t, watchPattern.allowReload(fileName, eventType, 0))
}
func TestDisallowOnPathTypeBiggerThan2(t *testing.T) {
const fileName = "/some/path/watch-me.php"
const pathType = 3
watchPattern, err := parseFilePattern("/some/path")
assert.NoError(t, err)
assert.False(t, watchPattern.allowReload(fileName, 0, pathType))
}
func TestWatchesCorrectDir(t *testing.T) {
hasDir(t, "/path", "/path")
hasDir(t, "/path/", "/path")
hasDir(t, "/path/**/*.php", "/path")
hasDir(t, "/path/*.php", "/path")
hasDir(t, "/path/*/*.php", "/path")
hasDir(t, "/path/?dir/*.php", "/path")
hasDir(t, ".", relativeDir(t, ""))
hasDir(t, "./", relativeDir(t, ""))
hasDir(t, "./**", relativeDir(t, ""))
hasDir(t, "..", relativeDir(t, "/.."))
}
func TestValidRecursiveDirectories(t *testing.T) {
shouldMatch(t, "/path", "/path/file.php")
shouldMatch(t, "/path", "/path/subpath/file.php")
shouldMatch(t, "/path/", "/path/subpath/file.php")
shouldMatch(t, "/path**", "/path/subpath/file.php")
shouldMatch(t, "/path/**", "/path/subpath/file.php")
shouldMatch(t, "/path/**/", "/path/subpath/file.php")
shouldMatch(t, ".", relativeDir(t, "file.php"))
shouldMatch(t, ".", relativeDir(t, "subpath/file.php"))
shouldMatch(t, "./**", relativeDir(t, "subpath/file.php"))
shouldMatch(t, "..", relativeDir(t, "subpath/file.php"))
}
func TestInvalidRecursiveDirectories(t *testing.T) {
shouldNotMatch(t, "/path", "/other/file.php")
shouldNotMatch(t, "/path/**", "/other/file.php")
shouldNotMatch(t, ".", "/other/file.php")
}
func TestValidNonRecursiveFilePatterns(t *testing.T) {
shouldMatch(t, "/*.php", "/file.php")
shouldMatch(t, "/path/*.php", "/path/file.php")
shouldMatch(t, "/path/?ile.php", "/path/file.php")
shouldMatch(t, "/path/file.php", "/path/file.php")
shouldMatch(t, "*.php", relativeDir(t, "file.php"))
shouldMatch(t, "./*.php", relativeDir(t, "file.php"))
}
func TestInValidNonRecursiveFilePatterns(t *testing.T) {
shouldNotMatch(t, "/path/*.txt", "/path/file.php")
shouldNotMatch(t, "/path/*.php", "/path/subpath/file.php")
shouldNotMatch(t, "/*.php", "/path/file.php")
shouldNotMatch(t, "*.txt", relativeDir(t, "file.php"))
shouldNotMatch(t, "*.php", relativeDir(t, "subpath/file.php"))
}
func TestValidRecursiveFilePatterns(t *testing.T) {
shouldMatch(t, "/path/**/*.php", "/path/file.php")
shouldMatch(t, "/path/**/*.php", "/path/subpath/file.php")
shouldMatch(t, "/path/**/?ile.php", "/path/subpath/file.php")
shouldMatch(t, "/path/**/file.php", "/path/subpath/file.php")
shouldMatch(t, "**/*.php", relativeDir(t, "file.php"))
shouldMatch(t, "**/*.php", relativeDir(t, "subpath/file.php"))
shouldMatch(t, "./**/*.php", relativeDir(t, "subpath/file.php"))
}
func TestInvalidRecursiveFilePatterns(t *testing.T) {
shouldNotMatch(t, "/path/**/*.txt", "/path/file.php")
shouldNotMatch(t, "/path/**/*.txt", "/other/file.php")
shouldNotMatch(t, "/path/**/*.txt", "/path/subpath/file.php")
shouldNotMatch(t, "/path/**/?ilm.php", "/path/subpath/file.php")
shouldNotMatch(t, "**/*.php", "/other/file.php")
shouldNotMatch(t, ".**/*.php", "/other/file.php")
shouldNotMatch(t, "./**/*.php", "/other/file.php")
}
func TestValidDirectoryPatterns(t *testing.T) {
shouldMatch(t, "/path/*/*.php", "/path/subpath/file.php")
shouldMatch(t, "/path/*/*/*.php", "/path/subpath/subpath/file.php")
shouldMatch(t, "/path/?/*.php", "/path/1/file.php")
shouldMatch(t, "/path/**/vendor/*.php", "/path/vendor/file.php")
shouldMatch(t, "/path/**/vendor/*.php", "/path/subpath/vendor/file.php")
shouldMatch(t, "/path/**/vendor/**/*.php", "/path/vendor/file.php")
shouldMatch(t, "/path/**/vendor/**/*.php", "/path/subpath/subpath/vendor/subpath/subpath/file.php")
shouldMatch(t, "/path/**/vendor/*/*.php", "/path/subpath/subpath/vendor/subpath/file.php")
shouldMatch(t, "/path*/path*/*", "/path1/path2/file.php")
}
func TestInvalidDirectoryPatterns(t *testing.T) {
shouldNotMatch(t, "/path/subpath/*.php", "/path/other/file.php")
shouldNotMatch(t, "/path/*/*.php", "/path/subpath/subpath/file.php")
shouldNotMatch(t, "/path/?/*.php", "/path/subpath/file.php")
shouldNotMatch(t, "/path/*/*/*.php", "/path/subpath/file.php")
shouldNotMatch(t, "/path/*/*/*.php", "/path/subpath/subpath/subpath/file.php")
shouldNotMatch(t, "/path/**/vendor/*.php", "/path/subpath/vendor/subpath/file.php")
shouldNotMatch(t, "/path/**/vendor/*.php", "/path/subpath/file.php")
shouldNotMatch(t, "/path/**/vendor/**/*.php", "/path/subpath/file.php")
shouldNotMatch(t, "/path/**/vendor/**/*.txt", "/path/subpath/vendor/subpath/file.php")
shouldNotMatch(t, "/path/**/vendor/**/*.php", "/path/subpath/subpath/subpath/file.php")
shouldNotMatch(t, "/path/**/vendor/*/*.php", "/path/subpath/vendor/subpath/subpath/file.php")
shouldNotMatch(t, "/path*/path*", "/path1/path1/file.php")
}
func TestValidExtendedPatterns(t *testing.T) {
shouldMatch(t, "/path/*.{php}", "/path/file.php")
shouldMatch(t, "/path/*.{php,twig}", "/path/file.php")
shouldMatch(t, "/path/*.{php,twig}", "/path/file.twig")
shouldMatch(t, "/path/**/{file.php,file.twig}", "/path/subpath/file.twig")
shouldMatch(t, "/path/{folder1,folder2}/file.php", "/path/folder1/file.php")
}
func TestInValidExtendedPatterns(t *testing.T) {
shouldNotMatch(t, "/path/*.{php}", "/path/file.txt")
shouldNotMatch(t, "/path/*.{php,twig}", "/path/file.txt")
shouldNotMatch(t, "/path/{file.php,file.twig}", "/path/file.txt")
shouldNotMatch(t, "/path/{folder1,folder2}/file.php", "/path/folder3/file.php")
}
func relativeDir(t *testing.T, relativePath string) string {
dir, err := filepath.Abs("./" + relativePath)
assert.NoError(t, err)
return dir
}
func hasDir(t *testing.T, pattern string, dir string) {
watchPattern, err := parseFilePattern(pattern)
assert.NoError(t, err)
assert.Equal(t, dir, watchPattern.dir)
}
func shouldMatch(t *testing.T, pattern string, fileName string) {
watchPattern, err := parseFilePattern(pattern)
assert.NoError(t, err)
assert.True(t, watchPattern.allowReload(fileName, 0, 0))
}
func shouldNotMatch(t *testing.T, pattern string, fileName string) {
watchPattern, err := parseFilePattern(pattern)
assert.NoError(t, err)
assert.False(t, watchPattern.allowReload(fileName, 0, 0))
}

79
watcher/watcher-c.h Normal file
View File

@@ -0,0 +1,79 @@
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Represents "what happened" to a path. */
static const int8_t WTR_WATCHER_EFFECT_RENAME = 0;
static const int8_t WTR_WATCHER_EFFECT_MODIFY = 1;
static const int8_t WTR_WATCHER_EFFECT_CREATE = 2;
static const int8_t WTR_WATCHER_EFFECT_DESTROY = 3;
static const int8_t WTR_WATCHER_EFFECT_OWNER = 4;
static const int8_t WTR_WATCHER_EFFECT_OTHER = 5;
/* Represents "what kind" of path it is. */
static const int8_t WTR_WATCHER_PATH_DIR = 0;
static const int8_t WTR_WATCHER_PATH_FILE = 1;
static const int8_t WTR_WATCHER_PATH_HARD_LINK = 2;
static const int8_t WTR_WATCHER_PATH_SYM_LINK = 3;
static const int8_t WTR_WATCHER_PATH_WATCHER = 4;
static const int8_t WTR_WATCHER_PATH_OTHER = 5;
/* The `event` object is used to carry information about
filesystem events to the user through the (user-supplied)
callback given to `watch`.
The `event` object will contain the:
- `path_name`: The path to the event.
- `path_type`: One of:
- dir
- file
- hard_link
- sym_link
- watcher
- other
- `effect_type`: One of:
- rename
- modify
- create
- destroy
- owner
- other
- `effect_time`:
The time of the event in nanoseconds since epoch.
*/
struct wtr_watcher_event {
int64_t effect_time;
char const *path_name;
char const *associated_path_name;
int8_t effect_type;
int8_t path_type;
};
/* Ensure the user's callback can receive
events and will return nothing. */
typedef void (*wtr_watcher_callback)(struct wtr_watcher_event event,
void *context);
void *wtr_watcher_open(char const *const path, wtr_watcher_callback callback,
void *context);
bool wtr_watcher_close(void *watcher);
/* The user, or the language we're working with,
might not prefer a callback-style API.
We provide a pipe-based API for these cases.
Instead of forwarding events to a callback,
we write json-serialized events to a pipe. */
void *wtr_watcher_open_pipe(char const *const path, int *read_fd,
int *write_fd);
bool wtr_watcher_close_pipe(void *watcher, int read_fd, int write_fd);
#ifdef __cplusplus
}
#endif

22
watcher/watcher.c Normal file
View File

@@ -0,0 +1,22 @@
#include "_cgo_export.h"
#include "watcher-c.h"
void handle_event(struct wtr_watcher_event event, void *data) {
go_handle_file_watcher_event((char *)event.path_name, event.effect_type,
event.path_type, (uintptr_t)data);
}
uintptr_t start_new_watcher(char const *const path, uintptr_t data) {
void *watcher = wtr_watcher_open(path, handle_event, (void *)data);
if (watcher == NULL) {
return 0;
}
return (uintptr_t)watcher;
}
int stop_watcher(uintptr_t watcher) {
if (!wtr_watcher_close((void *)watcher)) {
return 0;
}
return 1;
}

145
watcher/watcher.go Normal file
View File

@@ -0,0 +1,145 @@
package watcher
// #cgo LDFLAGS: -lwatcher -lstdc++
// #cgo CFLAGS: -Wall -Werror
// #include <stdint.h>
// #include <stdlib.h>
// #include "watcher.h"
import "C"
import (
"errors"
"go.uber.org/zap"
"runtime/cgo"
"sync"
"time"
"unsafe"
)
type watcher struct {
sessions []C.uintptr_t
callback func()
trigger chan struct{}
stop chan struct{}
}
// duration to wait before triggering a reload after a file change
const debounceDuration = 150 * time.Millisecond
var (
// the currently active file watcher
activeWatcher *watcher
// after stopping the watcher we will wait for eventual reloads to finish
reloadWaitGroup sync.WaitGroup
// we are passing the logger from the main package to the watcher
logger *zap.Logger
AlreadyStartedError = errors.New("The watcher is already running")
UnableToStartWatching = errors.New("Unable to start the watcher")
)
func InitWatcher(filePatterns []string, callback func(), zapLogger *zap.Logger) error {
if len(filePatterns) == 0 {
return nil
}
if activeWatcher != nil {
return AlreadyStartedError
}
logger = zapLogger
activeWatcher = &watcher{callback: callback}
err := activeWatcher.startWatching(filePatterns)
if err != nil {
return err
}
reloadWaitGroup = sync.WaitGroup{}
return nil
}
func DrainWatcher() {
if activeWatcher == nil {
return
}
logger.Debug("stopping watcher")
activeWatcher.stopWatching()
reloadWaitGroup.Wait()
activeWatcher = nil
}
func (w *watcher) startWatching(filePatterns []string) error {
w.trigger = make(chan struct{})
w.stop = make(chan struct{})
w.sessions = make([]C.uintptr_t, len(filePatterns))
watchPatterns, err := parseFilePatterns(filePatterns)
if err != nil {
return err
}
for i, watchPattern := range watchPatterns {
watchPattern.trigger = w.trigger
session, err := startSession(watchPattern)
if err != nil {
return err
}
w.sessions[i] = session
}
go listenForFileEvents(w.trigger, w.stop)
return nil
}
func (w *watcher) stopWatching() {
close(w.stop)
for _, session := range w.sessions {
stopSession(session)
}
}
func startSession(w *watchPattern) (C.uintptr_t, error) {
handle := cgo.NewHandle(w)
cDir := C.CString(w.dir)
defer C.free(unsafe.Pointer(cDir))
watchSession := C.start_new_watcher(cDir, C.uintptr_t(handle))
if watchSession != 0 {
logger.Debug("watching", zap.String("dir", w.dir), zap.Strings("patterns", w.patterns))
return watchSession, nil
}
logger.Error("couldn't start watching", zap.String("dir", w.dir))
return watchSession, UnableToStartWatching
}
func stopSession(session C.uintptr_t) {
success := C.stop_watcher(session)
if success == 0 {
logger.Warn("couldn't close the watcher")
}
}
//export go_handle_file_watcher_event
func go_handle_file_watcher_event(path *C.char, eventType C.int, pathType C.int, handle C.uintptr_t) {
watchPattern := cgo.Handle(handle).Value().(*watchPattern)
if watchPattern.allowReload(C.GoString(path), int(eventType), int(pathType)) {
watchPattern.trigger <- struct{}{}
}
}
func listenForFileEvents(triggerWatcher chan struct{}, stopWatcher chan struct{}) {
timer := time.NewTimer(debounceDuration)
timer.Stop()
defer timer.Stop()
for {
select {
case <-stopWatcher:
break
case <-triggerWatcher:
timer.Reset(debounceDuration)
case <-timer.C:
timer.Stop()
scheduleReload()
}
}
}
func scheduleReload() {
logger.Info("filesystem change detected")
reloadWaitGroup.Add(1)
activeWatcher.callback()
reloadWaitGroup.Done()
}

6
watcher/watcher.h Normal file
View File

@@ -0,0 +1,6 @@
#include <stdint.h>
#include <stdlib.h>
uintptr_t start_new_watcher(char const *const path, uintptr_t data);
int stop_watcher(uintptr_t watcher);

View File

@@ -10,8 +10,10 @@ import (
"path/filepath"
"runtime/cgo"
"sync"
"sync/atomic"
"time"
"github.com/dunglas/frankenphp/watcher"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
@@ -19,10 +21,18 @@ import (
var (
workersRequestChans sync.Map // map[fileName]chan *http.Request
workersReadyWG sync.WaitGroup
workerShutdownWG sync.WaitGroup
workersAreReady atomic.Bool
workersAreDone atomic.Bool
workersDone chan interface{}
)
// TODO: start all the worker in parallel to reduce the boot time
func initWorkers(opt []workerOpt) error {
workersDone = make(chan interface{})
workersAreReady.Store(false)
workersAreDone.Store(false)
for _, w := range opt {
if err := startWorkers(w.fileName, w.num, w.env); err != nil {
return err
@@ -38,12 +48,12 @@ func startWorkers(fileName string, nbWorkers int, env PreparedEnv) error {
return fmt.Errorf("workers %q: %w", fileName, err)
}
if _, ok := workersRequestChans.Load(absFileName); ok {
return fmt.Errorf("workers %q: already started", absFileName)
if _, ok := workersRequestChans.Load(absFileName); !ok {
workersRequestChans.Store(absFileName, make(chan *http.Request))
}
workersRequestChans.Store(absFileName, make(chan *http.Request))
shutdownWG.Add(nbWorkers)
workerShutdownWG.Add(nbWorkers)
workersReadyWG.Add(nbWorkers)
var (
@@ -59,13 +69,14 @@ func startWorkers(fileName string, nbWorkers int, env PreparedEnv) error {
l := getLogger()
const maxBackoff = 16 * time.Second
const minBackoff = 100 * time.Millisecond
const maxConsecutiveFailures = 3
const maxBackoff = 1 * time.Second
const minBackoff = 10 * time.Millisecond
const maxConsecutiveFailures = 60
for i := 0; i < nbWorkers; i++ {
go func() {
defer shutdownWG.Done()
defer workerShutdownWG.Done()
backoff := minBackoff
failureCount := 0
backingOffLock := sync.RWMutex{}
@@ -126,13 +137,11 @@ func startWorkers(fileName string, nbWorkers int, env PreparedEnv) error {
}
// TODO: make the max restart configurable
if _, ok := workersRequestChans.Load(absFileName); ok {
if !workersAreDone.Load() {
if fc.ready {
fc.ready = false
workersReadyWG.Add(1)
}
workersReadyWG.Add(1)
if fc.exitStatus == 0 {
if c := l.Check(zapcore.InfoLevel, "restarting"); c != nil {
c.Write(zap.String("worker", absFileName))
@@ -145,6 +154,7 @@ func startWorkers(fileName string, nbWorkers int, env PreparedEnv) error {
backingOffLock.Unlock()
metrics.StopWorker(absFileName, StopReasonRestart)
} else {
// we will wait a few milliseconds to not overwhelm the logger in case of repeated unexpected terminations
if c := l.Check(zapcore.ErrorLevel, "unexpected termination, restarting"); c != nil {
backingOffLock.RLock()
c.Write(zap.String("worker", absFileName), zap.Int("failure_count", failureCount), zap.Int("exit_status", int(fc.exitStatus)), zap.Duration("waiting", backoff))
@@ -187,6 +197,7 @@ func startWorkers(fileName string, nbWorkers int, env PreparedEnv) error {
}
workersReadyWG.Wait()
workersAreReady.Store(true)
m.Lock()
defer m.Unlock()
@@ -198,11 +209,41 @@ func startWorkers(fileName string, nbWorkers int, env PreparedEnv) error {
}
func stopWorkers() {
workersRequestChans.Range(func(k, v any) bool {
workersRequestChans.Delete(k)
workersAreDone.Store(true)
close(workersDone)
}
return true
})
func drainWorkers() {
watcher.DrainWatcher()
stopWorkers()
workerShutdownWG.Wait()
workersRequestChans = sync.Map{}
}
func restartWorkersOnFileChanges(workerOpts []workerOpt) error {
directoriesToWatch := []string{}
for _, w := range workerOpts {
directoriesToWatch = append(directoriesToWatch, w.watch...)
}
restartWorkers := func() {
restartWorkers(workerOpts)
}
if err := watcher.InitWatcher(directoriesToWatch, restartWorkers, getLogger()); err != nil {
return err
}
return nil
}
func restartWorkers(workerOpts []workerOpt) {
stopWorkers()
workerShutdownWG.Wait()
if err := initWorkers(workerOpts); err != nil {
logger.Error("failed to restart workers when watching files")
panic(err)
}
logger.Info("workers restarted successfully")
}
//export go_frankenphp_worker_ready
@@ -211,7 +252,9 @@ func go_frankenphp_worker_ready(mrh C.uintptr_t) {
fc := mainRequest.Context().Value(contextKey).(*FrankenPHPContext)
fc.ready = true
metrics.ReadyWorker(fc.scriptFilename)
workersReadyWG.Done()
if !workersAreReady.Load() {
workersReadyWG.Done()
}
}
//export go_frankenphp_worker_handle_request_start
@@ -234,10 +277,11 @@ func go_frankenphp_worker_handle_request_start(mrh C.uintptr_t) C.uintptr_t {
var r *http.Request
select {
case <-done:
case <-workersDone:
if c := l.Check(zapcore.DebugLevel, "shutting down"); c != nil {
c.Write(zap.String("worker", fc.scriptFilename))
}
executePHPFunction("opcache_reset")
return 0
case r = <-rc:

View File

@@ -117,8 +117,8 @@ func TestWorkerGetOpt(t *testing.T) {
func ExampleServeHTTP_workers() {
if err := frankenphp.Init(
frankenphp.WithWorkers("worker1.php", 4, map[string]string{"ENV1": "foo"}),
frankenphp.WithWorkers("worker2.php", 2, map[string]string{"ENV2": "bar"}),
frankenphp.WithWorkers("worker1.php", 4, map[string]string{"ENV1": "foo"}, []string{}),
frankenphp.WithWorkers("worker2.php", 2, map[string]string{"ENV2": "bar"}, []string{}),
); err != nil {
panic(err)
}