Compare commits

...

24 Commits

Author SHA1 Message Date
dependabot[bot]
6e6f665d82 ci: bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/download-artifact](https://github.com/actions/download-artifact) and [actions/cache](https://github.com/actions/cache).


Updates `actions/upload-artifact` from 5 to 6
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

Updates `actions/download-artifact` from 6 to 7
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-22 11:07:22 +00:00
Kévin Dunglas
57c58faf1c chore: prepare release 1.11.1 2025-12-20 09:16:23 +01:00
Loric Brevet
25d9cb9600 fix: crash when using the logger outside of the a request context 2025-12-20 09:15:29 +01:00
Kévin Dunglas
4092ecb5b5 fix: frankenphp_log() level parameter must be optional 2025-12-19 16:25:32 +01:00
Kévin Dunglas
75ccccf1b2 fix(caddy): use default patterns when hot_reload is alone 2025-12-19 09:38:05 +01:00
Kévin Dunglas
6231bf4a1c chore: prepare release 1.11.0 2025-12-18 16:51:41 +01:00
Kévin Dunglas
e01e40fd97 chore: bump deps (#2078) 2025-12-17 11:47:14 +01:00
Alexander Stecher
175e644d10 feat: multiple curly braces for watcher (#2068)
Allows doing something like this:

```caddyfile
watch "/app/{config,src}/*.{php,js}"
```

In the long term it would be nice to have pattern matching in the
watcher repo itself
2025-12-17 00:22:28 +01:00
Kévin Dunglas
a8f75d0eef ci: verbose logs for StaticPHP (#2074) 2025-12-15 20:13:15 +01:00
Raphael Coeffic
91c553f3d9 feat: add support for structured logging with the frankenphp_log() PHP function (#1979)
As discussed in https://github.com/php/frankenphp/discussions/1961,
there is no real way to pass a severity/level to any log handler offered
by PHP that would make it to the FrankenPHP layer. This new function
allows applications embedding FrankenPHP to integrate PHP logging into
the application itself, thus offering a more streamlined experience.

---------

Co-authored-by: Quentin Burgess <qutn.burgess@gmail.com>
Co-authored-by: Kévin Dunglas <kevin@dunglas.fr>
2025-12-15 16:10:35 +01:00
Alexandre Daubois
7fca07ed67 feat(types): expose IsPacked to help dealing with hashmaps and lists in Go code 2025-12-15 15:35:21 +01:00
dependabot[bot]
3599299cde chore(caddy): bump github.com/spf13/cobra
Bumps the go-modules group in /caddy with 1 update: [github.com/spf13/cobra](https://github.com/spf13/cobra).


Updates `github.com/spf13/cobra` from 1.10.1 to 1.10.2
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.10.1...v1.10.2)

---
updated-dependencies:
- dependency-name: github.com/spf13/cobra
  dependency-version: 1.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 13:41:56 +01:00
Alexandre Daubois
bb1c3678dc feat(extgen): add support for callable in parameters (#1731) 2025-12-15 12:50:50 +01:00
dependabot[bot]
58a63703b4 ci: bump actions/checkout from 5 to 6 in the github-actions group
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 12:23:53 +01:00
Alexandre Daubois
694ab86cef doc(runtime): mention Symfony 7.4 native support for worker mode (#1668)
Fixes https://github.com/symfony/symfony-docs/issues/21099, related to
https://github.com/symfony/symfony/pull/60503
2025-12-14 17:06:28 +01:00
Marc
e23e0c571e update config doc for new deb/rpm packages (#2071) 2025-12-13 19:32:27 +01:00
Kévin Dunglas
f02e6f2f85 fix: update mercure_publish() to use the new GoPackedArray() API 2025-12-13 19:30:22 +01:00
Alexander Stecher
11213fd1de fix: returns a zend_array directly in types.go (#1894) 2025-12-12 22:55:58 +01:00
Alexandre Daubois
41da660088 fix(hot-reload): fix import (#2069) 2025-12-12 15:23:55 +01:00
Alexandre Daubois
599c92b15d tests(extgen): add integration tests (#1984)
Fix #1975
2025-12-12 14:32:00 +01:00
Kévin Dunglas
225ca409d3 feat: hot reload (#2031)
This patch brings hot reloading capabilities to PHP apps: in
development, the browser will automatically refresh the page when any
source file changes!
It's similar to HMR in JavaScript.

It is built on top of [the watcher
mechanism](https://frankenphp.dev/docs/config/#watching-for-file-changes)
and of the [Mercure](https://frankenphp.dev/docs/mercure/) integration.

Each time a watched file is modified, a Mercure update is sent, giving
the ability to the client to reload the page, or part of the page
(assets, images...).

Here is an example implementation:

```caddyfile
root ./public


mercure {
      subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY}
      anonymous
}

php_server {
      hot_reload
}
```

```php
<?php
header('Content-Type: text/html');
?>
<!DOCTYPE html>
<html lang="en">
<head>
<title>Test</title>
<script>
    const es = new EventSource('<?=$_SERVER['FRANKENPHP_HOT_RELOAD']?>');
    es.onmessage = () => location.reload();
</script>
</head>
<body>
Hello
```

I plan to create a helper JS library to handle more advanced cases
(reloading CSS, JS, etc), similar to [HotWire
Spark](https://github.com/hotwired/spark). Be sure to attend my
SymfonyCon to learn more!

There is still room for improvement:

- Provide an option to only trigger the update without reloading the
worker for some files (ex, images, JS, CSS...)
- Support classic mode (currently, only the worker mode is supported)
- Don't reload all workers when only the files used by one change

However, this PR is working as-is and can be merged as a first step.

This patch heavily refactors the watcher module. Maybe it will be
possible to extract it as a standalone library at some point (would be
useful to add a similar feature but not tight to PHP as a Caddy module).

---------

Signed-off-by: Kévin Dunglas <kevin@dunglas.fr>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-12 14:29:18 +01:00
Francis Lavoie
d2007620a4 docs: Fix file extension in FrankenPHP configuration (#2067)
Corrected the file extension for additional configuration files in
FrankenPHP section.

---------

Signed-off-by: Francis Lavoie <lavofr@gmail.com>
2025-12-11 21:40:34 +01:00
Kévin Dunglas
4ac024a1d0 fix: remove deprecated Mercure "transport_url" directive from Caddyfile 2025-12-10 15:40:21 +01:00
Kacper Rowiński
e0dcf42852 chore: bump github.com/smallstep/certificates/ from 0.28.4 to 0.29.0 2025-12-09 11:40:58 +01:00
85 changed files with 3576 additions and 1089 deletions

View File

@@ -208,7 +208,7 @@ jobs:
VARIANT: ${{ matrix.variant }}
- name: Upload builder metadata
if: fromJson(needs.prepare.outputs.push)
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: metadata-builder-${{ matrix.variant }}-${{ steps.prepare.outputs.sanitized_platform }}
path: /tmp/metadata/builder/*
@@ -216,7 +216,7 @@ jobs:
retention-days: 1
- name: Upload runner metadata
if: fromJson(needs.prepare.outputs.push)
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: metadata-runner-${{ matrix.variant }}-${{ steps.prepare.outputs.sanitized_platform }}
path: /tmp/metadata/runner/*
@@ -248,7 +248,7 @@ jobs:
target: ["builder", "runner"]
steps:
- name: Download metadata
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
pattern: metadata-${{ matrix.target }}-${{ matrix.variant }}-*
path: /tmp/metadata

View File

@@ -57,7 +57,7 @@ jobs:
echo archive="$(jq -r '.[] .source[] | select(.filename |endswith(".xz")) | "https://www.php.net/distributions/" + .filename' version.json)" >> "$GITHUB_OUTPUT"
- name: Cache PHP
id: cache-php
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: php/target
key: php-sanitizers-${{ matrix.sanitizer }}-${{ runner.arch }}-${{ steps.determine-php-version.outputs.version }}

View File

@@ -10,7 +10,7 @@ on:
- main
paths:
- "docker-bake.hcl"
- ".github/workflows/docker.yaml"
- ".github/workflows/static.yaml"
- "**cgo.go"
- "**Dockerfile"
- "**.c"
@@ -37,6 +37,7 @@ permissions:
env:
IMAGE_NAME: ${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.version) || startsWith(github.ref, 'refs/tags/')) && 'dunglas/frankenphp' || 'dunglas/frankenphp-dev' }}
SPC_OPT_BUILD_ARGS: --debug
GOTOOLCHAIN: local
jobs:
@@ -169,7 +170,7 @@ jobs:
METADATA: ${{ steps.build.outputs.metadata }}
- name: Upload metadata
if: fromJson(needs.prepare.outputs.push) && !matrix.debug && !matrix.mimalloc
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: metadata-static-builder-musl-${{ steps.prepare.outputs.sanitized_platform }}
path: /tmp/metadata/*
@@ -187,7 +188,7 @@ jobs:
PLATFORM: ${{ matrix.platform }}
- name: Upload artifact
if: ${{ !fromJson(needs.prepare.outputs.push) }}
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: frankenphp-linux-${{ matrix.platform == 'linux/amd64' && 'x86_64' || 'aarch64' }}${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }}
path: frankenphp-linux-${{ matrix.platform == 'linux/amd64' && 'x86_64' || 'aarch64' }}${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }}
@@ -319,7 +320,7 @@ jobs:
METADATA: ${{ steps.build.outputs.metadata }}
- name: Upload metadata
if: fromJson(needs.prepare.outputs.push)
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: metadata-static-builder-gnu-${{ steps.prepare.outputs.sanitized_platform }}
path: /tmp/metadata-gnu/*
@@ -343,7 +344,7 @@ jobs:
PLATFORM: ${{ matrix.platform }}
- name: Upload artifact
if: ${{ !fromJson(needs.prepare.outputs.push) }}
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: frankenphp-linux-${{ matrix.platform == 'linux/amd64' && 'x86_64' || 'aarch64' }}-gnu-files
path: gh-output/*
@@ -379,13 +380,13 @@ jobs:
if: fromJson(needs.prepare.outputs.push)
steps:
- name: Download metadata
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
pattern: metadata-static-builder-musl-*
path: /tmp/metadata
merge-multiple: true
- name: Download GNU metadata
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
pattern: metadata-static-builder-gnu-*
path: /tmp/metadata-gnu
@@ -474,7 +475,7 @@ jobs:
NO_COMPRESS: ${{ github.event_name == 'pull_request' && '1' || '' }}
- name: Upload logs
if: ${{ failure() }}
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
path: dist/static-php-cli/log
name: static-php-cli-log-${{ matrix.platform }}-${{ github.sha }}
@@ -484,7 +485,7 @@ jobs:
subject-path: ${{ github.workspace }}/dist/frankenphp-mac-*
- name: Upload artifact
if: github.ref_type == 'branch'
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: frankenphp-mac-${{ matrix.platform }}
path: dist/frankenphp-mac-${{ matrix.platform }}

View File

@@ -93,6 +93,49 @@ jobs:
if: matrix.php-versions == '8.5'
run: go mod tidy -diff
working-directory: caddy/
integration-tests:
name: Integration Tests (Linux, PHP ${{ matrix.php-versions }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-versions: ["8.3", "8.4", "8.5"]
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version: "1.25"
cache-dependency-path: |
go.sum
caddy/go.sum
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-versions }}
ini-file: development
coverage: none
tools: none
env:
phpts: ts
debug: true
- name: Install PHP development libraries
run: sudo apt-get update && sudo apt-get install -y libkrb5-dev libsodium-dev libargon2-dev
- name: Install xcaddy
run: go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
- name: Download PHP sources
run: |
PHP_VERSION=$(php -r "echo PHP_VERSION;")
wget -q "https://www.php.net/distributions/php-${PHP_VERSION}.tar.gz"
tar xzf "php-${PHP_VERSION}.tar.gz"
echo "GEN_STUB_SCRIPT=${PWD}/php-${PHP_VERSION}/build/gen_stub.php" >> "${GITHUB_ENV}"
- name: Set CGO flags
run: |
echo "CGO_CFLAGS=$(php-config --includes)" >> "${GITHUB_ENV}"
echo "CGO_LDFLAGS=$(php-config --ldflags) $(php-config --libs)" >> "${GITHUB_ENV}"
- name: Run integration tests
working-directory: internal/extgen/
run: go test -tags integration -v -timeout 30m
tests-mac:
name: Tests (macOS, PHP 8.5)
runs-on: macos-latest
@@ -106,7 +149,7 @@ jobs:
with:
go-version: "1.25"
cache-dependency-path: |
go.sum
go.sum
caddy/go.sum
- uses: shivammathur/setup-php@v2
with:

View File

@@ -4,12 +4,13 @@ import (
"bytes"
"encoding/json"
"fmt"
"github.com/dunglas/frankenphp/internal/fastabs"
"io"
"net/http"
"sync"
"testing"
"github.com/dunglas/frankenphp/internal/fastabs"
"github.com/caddyserver/caddy/v2/caddytest"
"github.com/dunglas/frankenphp"
"github.com/stretchr/testify/assert"

View File

@@ -49,13 +49,14 @@ type FrankenPHPApp struct {
NumThreads int `json:"num_threads,omitempty"`
// MaxThreads limits how many threads can be started at runtime. Default 2x NumThreads
MaxThreads int `json:"max_threads,omitempty"`
// Workers configures the worker scripts to start.
// Workers configures the worker scripts to start
Workers []workerConfig `json:"workers,omitempty"`
// Overwrites the default php ini configuration
PhpIni map[string]string `json:"php_ini,omitempty"`
// The maximum amount of time a request may be stalled waiting for a thread
MaxWaitTime time.Duration `json:"max_wait_time,omitempty"`
opts []frankenphp.Option
metrics frankenphp.Metrics
ctx context.Context
logger *slog.Logger
@@ -76,6 +77,9 @@ func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
f.ctx = ctx
f.logger = ctx.Slogger()
// We have at least 7 hardcoded options
f.opts = make([]frankenphp.Option, 0, 7+len(options))
if httpApp, err := ctx.AppIfConfigured("http"); err == nil {
if httpApp.(*caddyhttp.App).Metrics != nil {
f.metrics = frankenphp.NewPrometheusMetrics(ctx.GetMetricsRegistry())
@@ -135,11 +139,10 @@ func (f *FrankenPHPApp) Start() error {
repl := caddy.NewReplacer()
optionsMU.RLock()
opts := make([]frankenphp.Option, 0, len(options)+len(f.Workers)+7)
opts = append(opts, options...)
f.opts = append(f.opts, options...)
optionsMU.RUnlock()
opts = append(opts,
f.opts = append(f.opts,
frankenphp.WithContext(f.ctx),
frankenphp.WithLogger(f.logger),
frankenphp.WithNumThreads(f.NumThreads),
@@ -150,31 +153,19 @@ func (f *FrankenPHPApp) Start() error {
)
for _, w := range f.Workers {
workerOpts := make([]frankenphp.WorkerOption, 0, len(w.requestOptions)+4)
w.options = append(w.options,
frankenphp.WithWorkerEnv(w.Env),
frankenphp.WithWorkerWatchMode(w.Watch),
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
)
if w.requestOptions == nil {
workerOpts = append(workerOpts,
frankenphp.WithWorkerEnv(w.Env),
frankenphp.WithWorkerWatchMode(w.Watch),
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
)
} else {
workerOpts = append(
workerOpts,
frankenphp.WithWorkerEnv(w.Env),
frankenphp.WithWorkerWatchMode(w.Watch),
frankenphp.WithWorkerMaxFailures(w.MaxConsecutiveFailures),
frankenphp.WithWorkerMaxThreads(w.MaxThreads),
frankenphp.WithWorkerRequestOptions(w.requestOptions...),
)
}
opts = append(opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, workerOpts...))
f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, repl.ReplaceKnown(w.FileName, ""), w.Num, w.options...))
}
frankenphp.Shutdown()
if err := frankenphp.Init(opts...); err != nil {
if err := frankenphp.Init(f.opts...); err != nil {
return err
}
@@ -288,7 +279,7 @@ func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
}
case "worker":
wc, err := parseWorkerConfig(d)
wc, err := unmarshalWorker(d)
if err != nil {
return err
}

View File

@@ -12,7 +12,7 @@ import (
const (
defaultDocumentRoot = "public"
defaultWatchPattern = "./**/*.{php,yaml,yml,twig,env}"
defaultWatchPattern = "./**/*.{env,php,twig,yaml,yml}"
)
func init() {

View File

@@ -1362,7 +1362,7 @@ func TestWorkerMatchDirective(t *testing.T) {
}
`, "caddyfile")
// worker is outside of public directory, match anyways
// worker is outside public directory, match anyway
tester.AssertGetResponse("http://localhost:"+testPort+"/matched-path", http.StatusOK, "requests:1")
tester.AssertGetResponse("http://localhost:"+testPort+"/matched-path/anywhere", http.StatusOK, "requests:2")
@@ -1472,3 +1472,31 @@ func TestDd(t *testing.T) {
"dump123",
)
}
func TestLog(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
}
http://localhost:`+testPort+` {
log {
output stdout
format json
}
root ../testdata
php_server {
worker ../testdata/log-frankenphp_log.php
}
}
`, "caddyfile")
tester.AssertGetResponse(
"http://localhost:"+testPort+"/log-frankenphp_log.php?i=0",
http.StatusOK,
"",
)
}

View File

@@ -181,7 +181,7 @@ func TestModuleWorkerWithWatchConfiguration(t *testing.T) {
// Verify that the watch directories were set correctly
require.Len(t, module.Workers[0].Watch, 3, "Expected three watch patterns")
require.Equal(t, "./**/*.{php,yaml,yml,twig,env}", module.Workers[0].Watch[0], "First watch pattern should be the default")
require.Equal(t, defaultWatchPattern, module.Workers[0].Watch[0], "First watch pattern should be the default")
require.Equal(t, "./src/**/*.php", module.Workers[0].Watch[1], "Second watch pattern should match the configuration")
require.Equal(t, "./config/**/*.yaml", module.Workers[0].Watch[2], "Third watch pattern should match the configuration")
}

View File

@@ -30,8 +30,6 @@
# Uncomment the following lines to enable Mercure and Vulcain modules
#mercure {
# # Publisher JWT key
# transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
# # Publisher JWT key
# publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
# # Subscriber JWT key
# subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}

View File

@@ -1,6 +1,6 @@
module github.com/dunglas/frankenphp/caddy
go 1.25.0
go 1.25.4
replace github.com/dunglas/frankenphp => ../
@@ -10,11 +10,12 @@ require (
github.com/caddyserver/caddy/v2 v2.10.2
github.com/caddyserver/certmagic v0.25.0
github.com/dunglas/caddy-cbrotli v1.0.1
github.com/dunglas/frankenphp v1.10.1
github.com/dunglas/mercure/caddy v0.21.2
github.com/dunglas/frankenphp v1.11.1
github.com/dunglas/mercure v0.21.4
github.com/dunglas/mercure/caddy v0.21.4
github.com/dunglas/vulcain/caddy v1.2.1
github.com/prometheus/client_golang v1.23.2
github.com/spf13/cobra v1.10.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
)
@@ -22,7 +23,7 @@ require github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth v0.18.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
@@ -36,15 +37,14 @@ require (
github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/alecthomas/chroma/v2 v2.21.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/ccoveille/go-safecast v1.8.2 // indirect
github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -59,10 +59,10 @@ require (
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/dunglas/mercure v0.21.2 // indirect
github.com/dunglas/skipfilter v1.0.0 // indirect
github.com/dunglas/vulcain v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
@@ -72,8 +72,8 @@ require (
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
@@ -100,7 +100,7 @@ require (
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.1 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/libdns/libdns v1.1.1 // indirect
@@ -111,7 +111,7 @@ require (
github.com/maypok86/otter/v2 v2.2.1 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mholt/acmez/v3 v3.1.4 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/miekg/dns v1.1.69 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
@@ -130,7 +130,7 @@ require (
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -139,7 +139,7 @@ require (
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slackhq/nebula v1.9.7 // indirect
github.com/smallstep/certificates v0.28.4 // indirect
github.com/smallstep/certificates v0.29.0 // indirect
github.com/smallstep/cli-utils v0.12.2 // indirect
github.com/smallstep/linkedca v0.25.0 // indirect
github.com/smallstep/nosql v0.7.0 // indirect
@@ -152,7 +152,8 @@ require (
github.com/spf13/viper v1.21.0 // indirect
github.com/stoewer/go-strcase v1.3.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -167,44 +168,44 @@ require (
github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/contrib/propagators/autoprop v0.63.0 // indirect
go.opentelemetry.io/contrib/propagators/aws v1.38.0 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.38.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect
go.opentelemetry.io/contrib/propagators/ot v1.38.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/contrib/propagators/autoprop v0.64.0 // indirect
go.opentelemetry.io/contrib/propagators/aws v1.39.0 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.39.0 // indirect
go.opentelemetry.io/contrib/propagators/ot v1.39.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/sdk v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.step.sm/crypto v0.74.0 // indirect
go.step.sm/crypto v0.75.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20251119195548-4e0068c0098b // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00 // indirect
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/api v0.256.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/api v0.257.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v1.0.1 // indirect

View File

@@ -1,9 +1,9 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c=
cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI=
cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0=
cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
@@ -12,8 +12,8 @@ cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/kms v1.23.2 h1:4IYDQL5hG4L+HzJBhzejUySoUOheh3Lk5YT4PCyyW6k=
cloud.google.com/go/kms v1.23.2/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
@@ -37,9 +37,6 @@ github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOh
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM=
github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
@@ -47,44 +44,46 @@ github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+a
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/chroma/v2 v2.21.0 h1:YVW9qQAFnQm2OFPPFQg6G/TpMxKSsUr/KUPDi/BEqtY=
github.com/alecthomas/chroma/v2 v2.21.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-sdk-go-v2 v1.39.5 h1:e/SXuia3rkFtapghJROrydtQpfQaaUgd1cUvyO1mp2w=
github.com/aws/aws-sdk-go-v2 v1.39.5/go.mod h1:yWSxrnioGUZ4WVv9TgMrNUeLV3PFESn/v+6T/Su8gnM=
github.com/aws/aws-sdk-go-v2/config v1.31.16 h1:E4Tz+tJiPc7kGnXwIfCyUj6xHJNpENlY11oKpRTgsjc=
github.com/aws/aws-sdk-go-v2/config v1.31.16/go.mod h1:2S9hBElpCyGMifv14WxQ7EfPumgoeCPZUpuPX8VtW34=
github.com/aws/aws-sdk-go-v2/credentials v1.18.20 h1:KFndAnHd9NUuzikHjQ8D5CfFVO+bgELkmcGY8yAw98Q=
github.com/aws/aws-sdk-go-v2/credentials v1.18.20/go.mod h1:9mCi28a+fmBHSQ0UM79omkz6JtN+PEsvLrnG36uoUv0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12 h1:VO3FIM2TDbm0kqp6sFNR0PbioXJb/HzCDW6NtIZpIWE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12/go.mod h1:6C39gB8kg82tx3r72muZSrNhHia9rjGkX7ORaS2GKNE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 h1:p/9flfXdoAnwJnuW9xHEAFY22R3A6skYkW19JFF9F+8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12/go.mod h1:ZTLHakoVCTtW8AaLGSwJ3LXqHD9uQKnOcv1TrpO6u2k=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 h1:2lTWFvRcnWFFLzHWmtddu5MTchc5Oj2OOey++99tPZ0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12/go.mod h1:hI92pK+ho8HVcWMHKHrK3Uml4pfG7wvL86FzO0LVtQQ=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/config v1.32.1 h1:iODUDLgk3q8/flEC7ymhmxjfoAnBDwEEYEVyKZ9mzjU=
github.com/aws/aws-sdk-go-v2/config v1.32.1/go.mod h1:xoAgo17AGrPpJBSLg81W+ikM0cpOZG8ad04T2r+d5P0=
github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 h1:xtuxji5CS0JknaXoACOunXOYOQzgfTvGAc9s2QdCJA4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2/go.mod h1:zxwi0DIR0rcRcgdbl7E2MSOvxDyyXGBlScvBkARFaLQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.12 h1:MM8imH7NZ0ovIVX7D2RxfMDv7Jt9OiUXkcQ+GqywA7M=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.12/go.mod h1:gf4OGwdNkbEsb7elw2Sy76odfhwNktWII3WgvQgQQ6w=
github.com/aws/aws-sdk-go-v2/service/kms v1.47.0 h1:A97YCVyGz19rRs3+dWf3GpMPflCswgETA9r6/Q0JNSY=
github.com/aws/aws-sdk-go-v2/service/kms v1.47.0/go.mod h1:ZJ1ghBt9gQM8JoNscUua1siIgao8w74o3kvdWUU6N/Q=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.0 h1:xHXvxst78wBpJFgDW07xllOx0IAzbryrSdM4nMVQ4Dw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.0/go.mod h1:/e8m+AO6HNPPqMyfKRtzZ9+mBF5/x1Wk8QiDva4m07I=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4 h1:tBw2Qhf0kj4ZwtsVpDiVRU3zKLvjvjgIjHMKirxXg8M=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4/go.mod h1:Deq4B7sRM6Awq/xyOBlxBdgW8/Z926KYNNaGMW2lrkA=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.0 h1:C+BRMnasSYFcgDw8o9H5hzehKzXyAb9GY5v/8bP9DUY=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.0/go.mod h1:4EjU+4mIx6+JqKQkruye+CaigV7alL3thVPfDd9VlMs=
github.com/aws/smithy-go v1.23.1 h1:sLvcH6dfAFwGkHLZ7dGiYF7aK6mg4CgKA/iDKjLDt9M=
github.com/aws/smithy-go v1.23.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/kms v1.48.0 h1:pQgVxqqNOacqb19+xaoih/wNLil4d8tgi+FxtBi/qQY=
github.com/aws/aws-sdk-go-v2/service/kms v1.48.0/go.mod h1:VJcNH6BLr+3VJwinRKdotLOMglHO8mIKlD3ea5c7hbw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 h1:U//SlnkE1wOQiIImxzdY5PXat4Wq+8rlfVEw4Y7J8as=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.4/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 h1:LU8S9W/mPDAU9q0FjCLi0TrCheLMGwzbRpvUMwYspcA=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
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/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
@@ -95,8 +94,8 @@ github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx6
github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/ccoveille/go-safecast v1.8.2 h1:+d+s5UGQiCVJX9oYc8XvYcB2zCMBlax6lIP7YdxXLHA=
github.com/ccoveille/go-safecast v1.8.2/go.mod h1:M0Ubpl11x63fE7iOfk5MtngQFXsntcRzOoSsFDqQYDY=
github.com/ccoveille/go-safecast/v2 v2.0.0 h1:+5eyITXAUj3wMjad6cRVJKGnC7vDS55zk0INzJagub0=
github.com/ccoveille/go-safecast/v2 v2.0.0/go.mod h1:JIYA4CAR33blIDuE6fSwCp2sz1oOBahXnvmdBhOAABs=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
@@ -146,10 +145,10 @@ github.com/dunglas/caddy-cbrotli v1.0.1 h1:mkg7EB1GmoyfBt3kY3mq4o/0bfnBeq7ZLQjmV
github.com/dunglas/caddy-cbrotli v1.0.1/go.mod h1:uXABy3tjy1FABF+3JWKVh1ajFvIO/kfpwHaeZGSBaAY=
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/dunglas/mercure v0.21.2 h1:qaLTScSwsCHDps++4AeLWrRp3BysdR5EoHBqu7JNhas=
github.com/dunglas/mercure v0.21.2/go.mod h1:3ElA7VwRI8BHUIAVU8oGlvPaqGwsKU5zZVWFNSFg/+U=
github.com/dunglas/mercure/caddy v0.21.2 h1:olBXnXbmLvqlawykVIUgjh/yTrp55hUO6NoIQeUX9x4=
github.com/dunglas/mercure/caddy v0.21.2/go.mod h1:mnzjwP1r6pYV01XR4euxPkBNwqoB9gJrIgQngscjkqs=
github.com/dunglas/mercure v0.21.4 h1:mXPXHfB+4cYfFFCRRDY198mfef5+MQcdCpUnAHBUW2M=
github.com/dunglas/mercure v0.21.4/go.mod h1:l/dglCjp/OQx8/quRyceRPx2hqZQ3CNviwoLMRQiJ/k=
github.com/dunglas/mercure/caddy v0.21.4 h1:7o+6rDqfwj1EOmXOgfBFsayvJvOUP37xujQHaxuX4ps=
github.com/dunglas/mercure/caddy v0.21.4/go.mod h1:EM2s+OVGExbSXObUdAPDwPsbQw4t/icLtQv9CFylDvY=
github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4=
github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w=
github.com/dunglas/vulcain v1.2.1 h1:pkPwvIfoa/xmWSVUyhntbIKT+XO2VFMyhLKv1gA61O8=
@@ -159,6 +158,8 @@ github.com/dunglas/vulcain/caddy v1.2.1/go.mod h1:8QrmLTfURmW2VgjTR6Gb9a53FrZjsp
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 h1:h3vVM6X45PK0mAk8NqiYNQGXTyhvXy1HQ5GhuQN4eeA=
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146/go.mod h1:sVUOkwtftoj71nnJRG2S0oWNfXFdKpz/M9vK0z06nmM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -181,10 +182,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
@@ -220,8 +221,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.4.6 h1:hwIwPG7w4z5eQEBq11gYw8YYr9xXLfBQ/0JsKyq5AJM=
github.com/google/go-tpm-tools v0.4.6/go.mod h1:MsVQbJnRhKDfWwf5zgr3cDGpj13P1uLAFF0wMEP/n5w=
github.com/google/go-tpm-tools v0.4.7 h1:J3ycC8umYxM9A4eF73EofRZu4BxY0jjQnUnkhIBbvws=
github.com/google/go-tpm-tools v0.4.7/go.mod h1:gSyXTZHe3fgbzb6WEGd90QucmsnT1SRdlye82gH8QjQ=
github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus=
github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
@@ -258,8 +259,8 @@ github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJS
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -289,8 +290,8 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ=
github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
@@ -338,8 +339,8 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
@@ -357,15 +358,14 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/slackhq/nebula v1.9.7 h1:v5u46efIyYHGdfjFnozQbRRhMdaB9Ma1SSTcUcE2lfE=
github.com/slackhq/nebula v1.9.7/go.mod h1:1+4q4wd3dDAjO8rKCttSb9JIVbklQhuJiBp5I0lbIsQ=
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY=
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc=
github.com/smallstep/certificates v0.28.4 h1:JTU6/A5Xes6m+OsR6fw1RACSA362vJc9SOFVG7poBEw=
github.com/smallstep/certificates v0.28.4/go.mod h1:LUqo+7mKZE7FZldlTb0zhU4A0bq4G4+akieFMcTaWvA=
github.com/smallstep/certificates v0.29.0 h1:f90szTKYTW62bmCc+qE5doGqIGPVxTQb8Ba37e/K8Zs=
github.com/smallstep/certificates v0.29.0/go.mod h1:27WI0od6gu84mvE4mYQ/QZGyYwHXvhsiSRNC+y3t+mo=
github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9doCVO+k=
github.com/smallstep/cli-utils v0.12.2/go.mod h1:uCPqefO29goHLGqFnwk0i8W7XJu18X3WHQFRtOm/00Y=
github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca h1:VX8L0r8vybH0bPeaIxh4NQzafKQiqvlOn8pmOXbFLO4=
@@ -390,8 +390,8 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -419,8 +419,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 h1:uxMgm0C+EjytfAqyfBG55ZONKQ7mvd7x4YYCWsf8QHQ=
github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53/go.mod h1:kNGUQ3VESx3VZwRwA9MSCUegIl6+saPL8Noq82ozCaU=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 h1:RnBbFMmodYzhC6adOjTbtUQXyzV8dcvKYbolzs6Qch0=
github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747/go.mod h1:ejPAJui3kVK4u5TgMtqtXlWf5HnKh9fLy5kvpaeuas0=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -464,36 +466,36 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/contrib/propagators/autoprop v0.63.0 h1:S3+4UwR3Y1tUKklruMwOacAFInNvtuOexz4ZTmJNAyw=
go.opentelemetry.io/contrib/propagators/autoprop v0.63.0/go.mod h1:qpIuOggbbw2T9nKRaO1je/oTRKd4zslAcJonN8LYbTg=
go.opentelemetry.io/contrib/propagators/aws v1.38.0 h1:eRZ7asSbLc5dH7+TBzL6hFKb1dabz0IV51uUUwYRZts=
go.opentelemetry.io/contrib/propagators/aws v1.38.0/go.mod h1:wXqc9NTGcXapBExHBDVLEZlByu6quiQL8w7Tjgv8TCg=
go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo=
go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU=
go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 h1:nXGeLvT1QtCAhkASkP/ksjkTKZALIaQBIW+JSIw1KIc=
go.opentelemetry.io/contrib/propagators/jaeger v1.38.0/go.mod h1:oMvOXk78ZR3KEuPMBgp/ThAMDy9ku/eyUVztr+3G6Wo=
go.opentelemetry.io/contrib/propagators/ot v1.38.0 h1:k4gSyyohaDXI8F9BDXYC3uO2vr5sRNeQFMsN9Zn0EoI=
go.opentelemetry.io/contrib/propagators/ot v1.38.0/go.mod h1:2hDsuiHRO39SRUMhYGqmj64z/IuMRoxE4bBSFR82Lo8=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/contrib/propagators/autoprop v0.64.0 h1:VVrb1ErDD0Tlh/0K0rUqjky1e8AekjspTFN9sU2ekaA=
go.opentelemetry.io/contrib/propagators/autoprop v0.64.0/go.mod h1:QCsOQk+9Ep8Mkp4/aPtSzUT0dc8SaPYzBAE6o1jYuSE=
go.opentelemetry.io/contrib/propagators/aws v1.39.0 h1:IvNR8pAVGpkK1CHMjU/YE6B6TlnAPGFvogkMWRWU6wo=
go.opentelemetry.io/contrib/propagators/aws v1.39.0/go.mod h1:TUsFCERuGM4IGhJG9w+9l0nzmHUKHuaDYYNF6mtNgjY=
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY=
go.opentelemetry.io/contrib/propagators/jaeger v1.39.0 h1:Gz3yKzfMSEFzF0Vy5eIpu9ndpo4DhXMCxsLMF0OOApo=
go.opentelemetry.io/contrib/propagators/jaeger v1.39.0/go.mod h1:2D/cxxCqTlrday0rZrPujjg5aoAdqk1NaNyoXn8FJn8=
go.opentelemetry.io/contrib/propagators/ot v1.39.0 h1:vKTve1W/WKPVp1fzJamhCDDECt+5upJJ65bPyWoddGg=
go.opentelemetry.io/contrib/propagators/ot v1.39.0/go.mod h1:FH5VB2N19duNzh1Q8ks6CsZFyu3LFhNLiA9lPxyEkvU=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.step.sm/crypto v0.74.0 h1:/APBEv45yYR4qQFg47HA8w1nesIGcxh44pGyQNw6JRA=
go.step.sm/crypto v0.74.0/go.mod h1:UoXqCAJjjRgzPte0Llaqen7O9P7XjPmgjgTHQGkKCDk=
go.step.sm/crypto v0.75.0 h1:UAHYD6q6ggYyzLlIKHv1MCUVjZIesXRZpGTlRC/HSHw=
go.step.sm/crypto v0.75.0/go.mod h1:wwQ57+ajmDype9mrI/2hRyrvJd7yja5xVgWYqpUN3PE=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -517,19 +519,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto/x509roots/fallback v0.0.0-20251119195548-4e0068c0098b h1:VI77LRI9gm150dbLwyi9yxd2VxVCm4mFzrZqkz7ahFo=
golang.org/x/crypto/x509roots/fallback v0.0.0-20251119195548-4e0068c0098b/go.mod h1:MEIPiCnxvQEjA4astfaKItNwEVZA5Ki+3+nyGbJ5N18=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00 h1:qObov2/X4yIpr98j5t6samg3mMF12Rl4taUJd1rWj+c=
golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00/go.mod h1:MEIPiCnxvQEjA4astfaKItNwEVZA5Ki+3+nyGbJ5N18=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -538,10 +540,10 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -549,13 +551,12 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -569,8 +570,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -580,8 +581,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -591,8 +592,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -601,25 +602,25 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA=
google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba h1:B14OtaXuMaCQsl2deSvNkyPKIzq3BjfxQp8d00QyWx4=
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0 h1:6Al3kEFFP9VJhRz3DID6quisgPnTeZVr4lep9kkxdPA=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0/go.mod h1:QLvsjh0OIR0TYBeiu2bkWGTJBUNQ64st52iWj/yA93I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

20
caddy/hotreload-skip.go Normal file
View File

@@ -0,0 +1,20 @@
//go:build nowatcher || nomercure
package caddy
import (
"errors"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
)
type hotReloadContext struct {
}
func (_ *FrankenPHPModule) configureHotReload(_ *FrankenPHPApp) error {
return nil
}
func (_ *FrankenPHPModule) unmarshalHotReload(d *caddyfile.Dispenser) error {
return errors.New("hot reload support disabled")
}

104
caddy/hotreload.go Normal file
View File

@@ -0,0 +1,104 @@
//go:build !nowatcher && !nomercure
package caddy
import (
"bytes"
"encoding/gob"
"errors"
"fmt"
"hash/fnv"
"net/url"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/dunglas/frankenphp"
)
const defaultHotReloadPattern = "./**/*.{css,env,gif,htm,html,jpg,jpeg,js,mjs,php,png,svg,twig,webp,xml,yaml,yml}"
type hotReloadContext struct {
// HotReload specifies files to watch for file changes to trigger hot reloads updates. Supports the glob syntax.
HotReload *hotReloadConfig `json:"hot_reload,omitempty"`
}
type hotReloadConfig struct {
Topic string `json:"topic"`
Watch []string `json:"watch"`
}
func (f *FrankenPHPModule) configureHotReload(app *FrankenPHPApp) error {
if f.HotReload == nil {
return nil
}
if f.mercureHub == nil {
return errors.New("unable to enable hot reloading: no Mercure hub configured")
}
if len(f.HotReload.Watch) == 0 {
f.HotReload.Watch = []string{defaultHotReloadPattern}
}
if f.HotReload.Topic == "" {
uid, err := uniqueID(f)
if err != nil {
return err
}
f.HotReload.Topic = "https://frankenphp.dev/hot-reload/" + uid
}
app.opts = append(app.opts, frankenphp.WithHotReload(f.HotReload.Topic, f.mercureHub, f.HotReload.Watch))
f.preparedEnv["FRANKENPHP_HOT_RELOAD\x00"] = "/.well-known/mercure?topic=" + url.QueryEscape(f.HotReload.Topic)
return nil
}
func (f *FrankenPHPModule) unmarshalHotReload(d *caddyfile.Dispenser) error {
f.HotReload = &hotReloadConfig{
Watch: d.RemainingArgs(),
}
for d.NextBlock(1) {
switch v := d.Val(); v {
case "topic":
if !d.NextArg() {
return d.ArgErr()
}
if f.HotReload == nil {
f.HotReload = &hotReloadConfig{}
}
f.HotReload.Topic = d.Val()
case "watch":
patterns := d.RemainingArgs()
if len(patterns) == 0 {
return d.ArgErr()
}
f.HotReload.Watch = append(f.HotReload.Watch, patterns...)
default:
return wrongSubDirectiveError("hot_reload", "topic, watch", v)
}
}
return nil
}
func uniqueID(s any) (string, error) {
var b bytes.Buffer
if err := gob.NewEncoder(&b).Encode(s); err != nil {
return "", fmt.Errorf("unable to generate unique name: %w", err)
}
h := fnv.New64a()
if _, err := h.Write(b.Bytes()); err != nil {
return "", fmt.Errorf("unable to generate unique name: %w", err)
}
return fmt.Sprintf("%016x", h.Sum64()), nil
}

88
caddy/hotreload_test.go Normal file
View File

@@ -0,0 +1,88 @@
//go:build !nowatcher && !nomercure
package caddy_test
import (
"context"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/caddyserver/caddy/v2/caddytest"
"github.com/stretchr/testify/require"
)
func TestHotReload(t *testing.T) {
const topic = "https://frankenphp.dev/hot-reload/test"
u := "/.well-known/mercure?topic=" + url.QueryEscape(topic)
tmpDir := t.TempDir()
indexFile := filepath.Join(tmpDir, "index.php")
tester := caddytest.NewTester(t)
tester.InitServer(`
{
debug
skip_install_trust
admin localhost:2999
}
http://localhost:`+testPort+` {
mercure {
transport local
subscriber_jwt TestKey
anonymous
}
php_server {
root `+tmpDir+`
hot_reload {
topic `+topic+`
watch `+tmpDir+`/*.php
}
}
`, "caddyfile")
var connected, received sync.WaitGroup
connected.Add(1)
received.Go(func() {
cx, cancel := context.WithCancel(t.Context())
req, _ := http.NewRequest(http.MethodGet, "http://localhost:"+testPort+u, nil)
req = req.WithContext(cx)
resp := tester.AssertResponseCode(req, http.StatusOK)
connected.Done()
var receivedBody strings.Builder
buf := make([]byte, 1024)
for {
_, err := resp.Body.Read(buf)
require.NoError(t, err)
receivedBody.Write(buf)
if strings.Contains(receivedBody.String(), "index.php") {
cancel()
break
}
}
require.NoError(t, resp.Body.Close())
})
connected.Wait()
require.NoError(t, os.WriteFile(indexFile, []byte("<?=$_SERVER['FRANKENPHP_HOT_RELOAD'];"), 0644))
received.Wait()
tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, u)
}

View File

@@ -2,9 +2,12 @@
package caddy
import (
"github.com/caddyserver/caddy/v2"
)
func (f *FrankenPHPModule) assignMercureHubRequestOption(_ caddy.Context) {
type mercureContext struct {
}
func (f *FrankenPHPModule) configureHotReload(_ *FrankenPHPApp) error {
return nil
}
func (f *FrankenPHPModule) assignMercureHub(_ caddy.Context) {
}

View File

@@ -5,6 +5,7 @@ package caddy
import (
"github.com/caddyserver/caddy/v2"
"github.com/dunglas/frankenphp"
"github.com/dunglas/mercure"
mercureCaddy "github.com/dunglas/mercure/caddy"
)
@@ -12,9 +13,22 @@ func init() {
mercureCaddy.AllowNoPublish = true
}
func (f *FrankenPHPModule) assignMercureHubRequestOption(ctx caddy.Context) {
if hub := mercureCaddy.FindHub(ctx.Modules()); hub != nil {
opt := frankenphp.WithMercureHub(hub)
f.mercureHubRequestOption = &opt
type mercureContext struct {
mercureHub *mercure.Hub
}
func (f *FrankenPHPModule) assignMercureHub(ctx caddy.Context) {
if f.mercureHub = mercureCaddy.FindHub(ctx.Modules()); f.mercureHub == nil {
return
}
opt := frankenphp.WithMercureHub(f.mercureHub)
f.mercureHubRequestOption = &opt
for i, wc := range f.Workers {
wc.mercureHub = f.mercureHub
wc.options = append(wc.options, frankenphp.WithWorkerMercureHub(wc.mercureHub))
f.Workers[i] = wc
}
}

View File

@@ -33,6 +33,9 @@ var serverHeader = []string{"FrankenPHP Caddy"}
// }
// }
type FrankenPHPModule struct {
mercureContext
hotReloadContext
// Root sets the root folder to the site. Default: `root` directive, or the path of the public directory of the embed app it exists.
Root string `json:"root,omitempty"`
// SplitPath sets the substrings for splitting the URI into two parts. The first matching substring will be used to split the "path info" from the path. The first piece is suffixed with the matching substring and will be assumed as the actual resource (CGI script) name. The second piece will be set to PATH_INFO for the CGI script to use. Default: `.php`.
@@ -74,8 +77,9 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
return fmt.Errorf(`expected ctx.App("frankenphp") to return *FrankenPHPApp, got nil`)
}
f.assignMercureHubRequestOption(ctx)
f.assignMercureHub(ctx)
loggerOpt := frankenphp.WithRequestLogger(f.logger)
for i, wc := range f.Workers {
// make the file path absolute from the public directory
// this can only be done if the root is defined inside php_server
@@ -88,11 +92,7 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
wc.inheritEnv(f.Env)
}
wc.requestOptions = []frankenphp.RequestOption{frankenphp.WithRequestLogger(f.logger)}
if f.mercureHubRequestOption != nil {
wc.requestOptions = append(wc.requestOptions, *f.mercureHubRequestOption)
}
wc.requestOptions = append(wc.requestOptions, loggerOpt)
f.Workers[i] = wc
}
@@ -106,14 +106,13 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
if frankenphp.EmbeddedAppPath == "" {
f.Root = "{http.vars.root}"
} else {
rrs := false
f.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot)
var rrs bool
f.ResolveRootSymlink = &rrs
}
} else {
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(f.Root) {
f.Root = filepath.Join(frankenphp.EmbeddedAppPath, f.Root)
}
} else if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(f.Root) {
f.Root = filepath.Join(frankenphp.EmbeddedAppPath, f.Root)
}
if len(f.SplitPath) == 0 {
@@ -154,6 +153,10 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error {
}
}
if err := f.configureHotReload(fapp); err != nil {
return err
}
return nil
}
@@ -164,11 +167,15 @@ func needReplacement(s string) bool {
// ServeHTTP implements caddyhttp.MiddlewareHandler.
func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ caddyhttp.Handler) error {
origReq := r.Context().Value(caddyhttp.OriginalRequestCtxKey).(http.Request)
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
ctx := r.Context()
origReq := ctx.Value(caddyhttp.OriginalRequestCtxKey).(http.Request)
repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
var (
documentRootOption frankenphp.RequestOption
documentRoot string
)
var documentRootOption frankenphp.RequestOption
var documentRoot string
if f.resolvedDocumentRoot == "" {
documentRoot = repl.ReplaceKnown(f.Root, "")
if documentRoot == "" && frankenphp.EmbeddedAppPath != "" {
@@ -278,14 +285,20 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
f.ResolveRootSymlink = &v
case "worker":
wc, err := parseWorkerConfig(d)
wc, err := unmarshalWorker(d)
if err != nil {
return err
}
f.Workers = append(f.Workers, wc)
case "hot_reload":
if err := f.unmarshalHotReload(d); err != nil {
return err
}
default:
return wrongSubDirectiveError("php or php_server", "root, split, env, resolve_root_symlink, worker", d.Val())
return wrongSubDirectiveError("php or php_server", "hot_reload, name, root, split, env, resolve_root_symlink, worker", d.Val())
}
}
}
@@ -294,7 +307,7 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
fileNames := make(map[string]struct{}, len(f.Workers))
for _, w := range f.Workers {
if _, ok := fileNames[w.FileName]; ok {
return fmt.Errorf(`workers in a single "php_server" block must not have duplicate filenames: %q`, w.FileName)
return fmt.Errorf(`workers in a single "php" or "php_server" block must not have duplicate filenames: %q`, w.FileName)
}
if len(w.MatchPath) == 0 {

View File

@@ -22,6 +22,8 @@ import (
// }
// }
type workerConfig struct {
mercureContext
// Name for the worker. Default: the filename for FrankenPHPApp workers, always prefixed with "m#" for FrankenPHPModule workers.
Name string `json:"name,omitempty"`
// FileName sets the path to the worker script.
@@ -39,10 +41,11 @@ type workerConfig struct {
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`
options []frankenphp.WorkerOption
requestOptions []frankenphp.RequestOption
}
func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) {
func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
wc := workerConfig{}
if d.NextArg() {
wc.FileName = d.Val()
@@ -66,8 +69,7 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) {
}
for d.NextBlock(1) {
v := d.Val()
switch v {
switch v := d.Val(); v {
case "name":
if !d.NextArg() {
return wc, d.ArgErr()
@@ -110,11 +112,12 @@ func parseWorkerConfig(d *caddyfile.Dispenser) (workerConfig, error) {
}
wc.Env[args[0]] = args[1]
case "watch":
if !d.NextArg() {
patterns := d.RemainingArgs()
if len(patterns) == 0 {
// the default if the watch directory is left empty:
wc.Watch = append(wc.Watch, defaultWatchPattern)
} else {
wc.Watch = append(wc.Watch, d.Val())
wc.Watch = append(wc.Watch, patterns...)
}
case "match":
// provision the path so it's identical to Caddy match rules

View File

@@ -14,6 +14,10 @@ variable "GO_VERSION" {
default = "1.25"
}
variable "SPC_OPT_BUILD_ARGS" {
default = ""
}
variable "SHA" {}
variable "LATEST" {
@@ -146,6 +150,7 @@ target "static-builder-musl" {
args = {
FRANKENPHP_VERSION = VERSION
CI = CI
SPC_OPT_BUILD_ARGS = SPC_OPT_BUILD_ARGS
}
secret = ["id=github-token,env=GITHUB_TOKEN"]
}
@@ -171,6 +176,7 @@ target "static-builder-gnu" {
FRANKENPHP_VERSION = VERSION
GO_VERSION = GO_VERSION
CI = CI
SPC_OPT_BUILD_ARGS = SPC_OPT_BUILD_ARGS
}
secret = ["id=github-token,env=GITHUB_TOKEN"]
}

View File

@@ -53,13 +53,12 @@ RUN cp $PHP_INI_DIR/php.ini-development $PHP_INI_DIR/php.ini
FrankenPHP:
- `/etc/frankenphp/Caddyfile`: the main configuration file
- `/etc/frankenphp/caddy.d/*.caddy`: additional configuration files that are loaded automatically
- `/etc/frankenphp/Caddyfile.d/*.caddyfile`: additional configuration files that are loaded automatically
PHP:
- `php.ini`: `/etc/frankenphp/php.ini` (a `php.ini` file with production presets is provided by default)
- additional configuration files: `/etc/frankenphp/php.d/*.ini`
- PHP extensions: `/usr/lib/frankenphp/modules/`
- `php.ini`: `/etc/php-zts/php.ini` (a `php.ini` file with production presets is provided by default)
- additional configuration files: `/etc/php-zts/conf.d/*.ini`
## Static binary

View File

@@ -88,19 +88,20 @@ While some variable types have the same memory representation between C/PHP and
This table summarizes what you need to know:
| PHP type | Go type | Direct conversion | C to Go helper | Go to C helper | Class Methods Support |
| ------------------ | ----------------------------- | ----------------- | --------------------------------- | ---------------------------------- | --------------------- |
| `int` | `int64` | ✅ | - | - | ✅ |
| `?int` | `*int64` | ✅ | - | - | ✅ |
| `float` | `float64` | ✅ | - | - | ✅ |
| `?float` | `*float64` | ✅ | - | - | ✅ |
| `bool` | `bool` | ✅ | - | - | ✅ |
| `?bool` | `*bool` | ✅ | - | - | ✅ |
| `string`/`?string` | `*C.zend_string` | ❌ | `frankenphp.GoString()` | `frankenphp.PHPString()` | ✅ |
| `array` | `frankenphp.AssociativeArray` | ❌ | `frankenphp.GoAssociativeArray()` | `frankenphp.PHPAssociativeArray()` | ✅ |
| `array` | `map[string]any` | ❌ | `frankenphp.GoMap()` | `frankenphp.PHPMap()` | ✅ |
| `array` | `[]any` | ❌ | `frankenphp.GoPackedArray()` | `frankenphp.PHPPackedArray()` | ✅ |
| `mixed` | `any` | ❌ | `GoValue()` | `PHPValue()` | ❌ |
| `object` | `struct` | ❌ | _Not yet implemented_ | _Not yet implemented_ | ❌ |
|--------------------|-------------------------------|-------------------|-----------------------------------|------------------------------------|-----------------------|
| `int` | `int64` | ✅ | - | - | ✅ |
| `?int` | `*int64` | ✅ | - | - | ✅ |
| `float` | `float64` | ✅ | - | - | ✅ |
| `?float` | `*float64` | ✅ | - | - | ✅ |
| `bool` | `bool` | ✅ | - | - | ✅ |
| `?bool` | `*bool` | ✅ | - | - | ✅ |
| `string`/`?string` | `*C.zend_string` | ❌ | `frankenphp.GoString()` | `frankenphp.PHPString()` | ✅ |
| `array` | `frankenphp.AssociativeArray` | ❌ | `frankenphp.GoAssociativeArray()` | `frankenphp.PHPAssociativeArray()` | ✅ |
| `array` | `map[string]any` | ❌ | `frankenphp.GoMap()` | `frankenphp.PHPMap()` | ✅ |
| `array` | `[]any` | ❌ | `frankenphp.GoPackedArray()` | `frankenphp.PHPPackedArray()` | ✅ |
| `mixed` | `any` | ❌ | `GoValue()` | `PHPValue()` | ❌ |
| `callable` | `*C.zval` | ❌ | - | frankenphp.CallPHPCallable() | ❌ |
| `object` | `struct` | ❌ | _Not yet implemented_ | _Not yet implemented_ | ❌ |
> [!NOTE]
>
@@ -132,7 +133,7 @@ import (
)
// export_php:function process_data_ordered(array $input): array
func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
func process_data_ordered_map(arr *C.zend_array) unsafe.Pointer {
// Convert PHP associative array to Go while keeping the order
associativeArray, err := frankenphp.GoAssociativeArray[any](unsafe.Pointer(arr))
if err != nil {
@@ -157,7 +158,7 @@ func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
}
// export_php:function process_data_unordered(array $input): array
func process_data_unordered_map(arr *C.zval) unsafe.Pointer {
func process_data_unordered_map(arr *C.zend_array) unsafe.Pointer {
// Convert PHP associative array to a Go map without keeping the order
// ignoring the order will be more performant
goMap, err := frankenphp.GoMap[any](unsafe.Pointer(arr))
@@ -178,7 +179,7 @@ func process_data_unordered_map(arr *C.zval) unsafe.Pointer {
}
// export_php:function process_data_packed(array $input): array
func process_data_packed(arr *C.zval) unsafe.Pointer {
func process_data_packed(arr *C.zend_array) unsafe.Pointer {
// Convert PHP packed array to Go
goSlice, err := frankenphp.GoPackedArray(unsafe.Pointer(arr))
if err != nil {
@@ -211,6 +212,43 @@ func process_data_packed(arr *C.zval) unsafe.Pointer {
- `frankenphp.GoAssociativeArray(arr unsafe.Pointer, ordered bool) frankenphp.AssociativeArray` - Convert a PHP array to an ordered Go `AssociativeArray` (map with order)
- `frankenphp.GoMap(arr unsafe.Pointer) map[string]any` - Convert a PHP array to an unordered Go map
- `frankenphp.GoPackedArray(arr unsafe.Pointer) []any` - Convert a PHP array to a Go slice
- `frankenphp.IsPacked(zval *C.zend_array) bool` - Check if a PHP array is packed (indexed only) or associative (key-value pairs)
### Working with Callables
FrankenPHP provides a way to work with PHP callables using the `frankenphp.CallPHPCallable` helper. This allows you to call PHP functions or methods from Go code.
To showcase this, let's create our own `array_map()` function that takes a callable and an array, applies the callable to each element of the array, and returns a new array with the results:
```go
// export_php:function my_array_map(array $data, callable $callback): array
func my_array_map(arr *C.zend_array, callback *C.zval) unsafe.Pointer {
goSlice, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr))
if err != nil {
panic(err)
}
result := make([]any, len(goSlice))
for index, value := range goSlice {
result[index] = frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value})
}
return frankenphp.PHPPackedArray(result)
}
```
Notice how we use `frankenphp.CallPHPCallable()` to call the PHP callable passed as a parameter. This function takes a pointer to the callable and an array of arguments, and it returns the result of the callable execution. You can use the callable syntax you're used to:
```php
<?php
$result = my_array_map([1, 2, 3], function($x) { return $x * 2; });
// $result will be [2, 4, 6]
$result = my_array_map(['hello', 'world'], 'strtoupper');
// $result will be ['HELLO', 'WORLD']
```
### Declaring a Native PHP Class

View File

@@ -209,6 +209,43 @@ func process_data_packed(arr *C.zval) unsafe.Pointer {
- `frankenphp.GoAssociativeArray(arr unsafe.Pointer, ordered bool) frankenphp.AssociativeArray` - Convertir un tableau PHP vers un `AssociativeArray` Go ordonné (map avec ordre)
- `frankenphp.GoMap(arr unsafe.Pointer) map[string]any` - Convertir un tableau PHP vers une map Go non ordonnée
- `frankenphp.GoPackedArray(arr unsafe.Pointer) []any` - Convertir un tableau PHP vers un slice Go
- `frankenphp.IsPacked(zval *C.zend_array) bool` - Vérifie si le tableau PHP est une liste ou un tableau associatif
### Travailler avec des Callables
FrankenPHP propose un moyen de travailler avec les _callables_ PHP grâce au helper `frankenphp.CallPHPCallable()`. Cela permet d'appeler des fonctions ou des méthodes PHP depuis du code Go.
Pour illustrer cela, créons notre propre fonction `array_map()` qui prend un _callable_ et un tableau, applique le _callable_ à chaque élément du tableau, et retourne un nouveau tableau avec les résultats :
```go
// export_php:function my_array_map(array $data, callable $callback): array
func my_array_map(arr *C.zend_array, callback *C.zval) unsafe.Pointer {
goSlice, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr))
if err != nil {
panic(err)
}
result := make([]any, len(goSlice))
for index, value := range goSlice {
result[index] = frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value})
}
return frankenphp.PHPPackedArray(result)
}
```
Remarquez comment nous utilisons `frankenphp.CallPHPCallable()` pour appeler le _callable_ PHP passé en paramètre. Cette fonction prend un pointeur vers le _callable_ et un tableau d'arguments, et elle retourne le résultat de l'exécution du _callable_. Vous pouvez utiliser la syntaxe habituelle des _callables_ :
```php
<?php
$result = my_array_map([1, 2, 3], function($x) { return $x * 2; });
// $result vaudra [2, 4, 6]
$result = my_array_map(['hello', 'world'], 'strtoupper');
// $result vaudra ['HELLO', 'WORLD']
```
### Déclarer une Classe PHP Native

View File

@@ -37,6 +37,9 @@ frankenphp php-server --worker /path/to/your/worker/script.php --watch="/path/to
## Runtime Symfony
> [!TIP]
> La section suivante est nécessaire uniquement avant Symfony 7.4, où le support natif du mode worker de FrankenPHP a été introduit.
Le mode worker de FrankenPHP est pris en charge par le [Composant Runtime de Symfony](https://symfony.com/doc/current/components/runtime.html).
Pour démarrer une application Symfony dans un worker, installez le package FrankenPHP de [PHP Runtime](https://github.com/php-runtime/runtime) :

View File

@@ -37,6 +37,9 @@ frankenphp php-server --worker /path/to/your/worker/script.php --watch="/path/to
## Symfony Runtime
> [!TIP]
> The following section is only necessary prior to Symfony 7.4, where native support for FrankenPHP worker mode was introduced.
The worker mode of FrankenPHP is supported by the [Symfony Runtime Component](https://symfony.com/doc/current/components/runtime.html).
To start any Symfony application in a worker, install the FrankenPHP package of [PHP Runtime](https://github.com/php-runtime/runtime):

View File

@@ -549,7 +549,30 @@ PHP_FUNCTION(mercure_publish) {
RETURN_THROWS();
}
PHP_FUNCTION(frankenphp_log) {
zend_string *message = NULL;
zend_long level = 0;
zval *context = NULL;
ZEND_PARSE_PARAMETERS_START(1, 3)
Z_PARAM_STR(message)
Z_PARAM_OPTIONAL
Z_PARAM_LONG(level)
Z_PARAM_ARRAY(context)
ZEND_PARSE_PARAMETERS_END();
char *ret = NULL;
ret = go_log_attrs(thread_index, message, level, context);
if (ret != NULL) {
zend_throw_exception(spl_ce_RuntimeException, ret, 0);
free(ret);
RETURN_THROWS();
}
}
PHP_MINIT_FUNCTION(frankenphp) {
register_frankenphp_symbols(module_number);
zend_function *func;
// Override putenv

View File

@@ -251,6 +251,7 @@ func Init(options ...Option) error {
opt := &opt{}
for _, o := range options {
if err := o(opt); err != nil {
Shutdown()
return err
}
}
@@ -277,6 +278,7 @@ func Init(options ...Option) error {
workerThreadCount, err := calculateMaxThreads(opt)
if err != nil {
Shutdown()
return err
}
@@ -285,6 +287,7 @@ func Init(options ...Option) error {
config := Config()
if config.Version.MajorVersion < 8 || (config.Version.MajorVersion == 8 && config.Version.MinorVersion < 2) {
Shutdown()
return ErrInvalidPHPVersion
}
@@ -304,6 +307,7 @@ func Init(options ...Option) error {
mainThread, err := initPHPThreads(opt.numThreads, opt.maxThreads, opt.phpIni)
if err != nil {
Shutdown()
return err
}
@@ -314,6 +318,13 @@ func Init(options ...Option) error {
}
if err := initWorkers(opt.workers); err != nil {
Shutdown()
return err
}
if err := initWatchers(opt); err != nil {
Shutdown()
return err
}
@@ -352,7 +363,7 @@ func Shutdown() {
fn()
}
drainWatcher()
drainWatchers()
drainAutoScaling()
drainPHPThreads()
@@ -649,9 +660,28 @@ func go_read_cookies(threadIndex C.uintptr_t) *C.char {
return C.CString(cookie)
}
func getLogger(threadIndex C.uintptr_t) (*slog.Logger, context.Context) {
ctxHolder := phpThreads[threadIndex]
if ctxHolder == nil {
return globalLogger, globalCtx
}
ctx := ctxHolder.context()
if ctxHolder.handler == nil {
return globalLogger, ctx
}
fCtx := ctxHolder.frankenPHPContext()
if fCtx == nil || fCtx.logger == nil {
return globalLogger, ctx
}
return fCtx.logger, ctx
}
//export go_log
func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) {
ctx := phpThreads[threadIndex].context()
logger, ctx := getLogger(threadIndex)
m := C.GoString(message)
le := syslogLevelInfo
@@ -662,26 +692,62 @@ func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) {
switch le {
case syslogLevelEmerg, syslogLevelAlert, syslogLevelCrit, syslogLevelErr:
if globalLogger.Enabled(ctx, slog.LevelError) {
globalLogger.LogAttrs(ctx, slog.LevelError, m, slog.String("syslog_level", syslogLevel(level).String()))
if logger.Enabled(ctx, slog.LevelError) {
logger.LogAttrs(ctx, slog.LevelError, m, slog.String("syslog_level", le.String()))
}
case syslogLevelWarn:
if globalLogger.Enabled(ctx, slog.LevelWarn) {
globalLogger.LogAttrs(ctx, slog.LevelWarn, m, slog.String("syslog_level", syslogLevel(level).String()))
if logger.Enabled(ctx, slog.LevelWarn) {
logger.LogAttrs(ctx, slog.LevelWarn, m, slog.String("syslog_level", le.String()))
}
case syslogLevelDebug:
if globalLogger.Enabled(ctx, slog.LevelDebug) {
globalLogger.LogAttrs(ctx, slog.LevelDebug, m, slog.String("syslog_level", syslogLevel(level).String()))
if logger.Enabled(ctx, slog.LevelDebug) {
logger.LogAttrs(ctx, slog.LevelDebug, m, slog.String("syslog_level", le.String()))
}
default:
if globalLogger.Enabled(ctx, slog.LevelInfo) {
globalLogger.LogAttrs(ctx, slog.LevelInfo, m, slog.String("syslog_level", syslogLevel(level).String()))
if logger.Enabled(ctx, slog.LevelInfo) {
logger.LogAttrs(ctx, slog.LevelInfo, m, slog.String("syslog_level", le.String()))
}
}
}
//export go_log_attrs
func go_log_attrs(threadIndex C.uintptr_t, message *C.zend_string, cLevel C.zend_long, cAttrs *C.zval) *C.char {
logger, ctx := getLogger(threadIndex)
level := slog.Level(cLevel)
if !logger.Enabled(ctx, level) {
return nil
}
var attrs map[string]any
if cAttrs != nil {
var err error
if attrs, err = GoMap[any](unsafe.Pointer(*(**C.zend_array)(unsafe.Pointer(&cAttrs.value[0])))); err != nil {
// PHP exception message.
return C.CString("Failed to log message: converting attrs: " + err.Error())
}
}
logger.LogAttrs(ctx, level, GoString(unsafe.Pointer(message)), mapToAttr(attrs)...)
return nil
}
func mapToAttr(input map[string]any) []slog.Attr {
out := make([]slog.Attr, 0, len(input))
for key, val := range input {
out = append(out, slog.Any(key, val))
}
return out
}
//export go_is_context_done
func go_is_context_done(threadIndex C.uintptr_t) C.bool {
return C.bool(phpThreads[threadIndex].frankenPHPContext().isDone)
@@ -739,5 +805,6 @@ func resetGlobals() {
globalCtx = context.Background()
globalLogger = slog.Default()
workers = nil
watcherIsEnabled = false
globalMu.Unlock()
}

View File

@@ -2,6 +2,18 @@
/** @generate-class-entries */
/** @var int */
const FRANKENPHP_LOG_LEVEL_DEBUG = -4;
/** @var int */
const FRANKENPHP_LOG_LEVEL_INFO = 0;
/** @var int */
const FRANKENPHP_LOG_LEVEL_WARN = 4;
/** @var int */
const FRANKENPHP_LOG_LEVEL_ERROR = 8;
function frankenphp_handle_request(callable $callback): bool {}
function headers_send(int $status = 200): int {}
@@ -36,3 +48,9 @@ function apache_response_headers(): array|bool {}
* @param string|string[] $topics
*/
function mercure_publish(string|array $topics, string $data = '', bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null): string {}
/**
* @param int $level The importance or severity of a log event. The higher the level, the more important or severe the event. For more details, see: https://pkg.go.dev/log/slog#Level
* array<string, any> $context Values of the array will be converted to the corresponding Go type (if supported by FrankenPHP) and added to the context of the structured logs using https://pkg.go.dev/log/slog#Attr
*/
function frankenphp_log(string $message, int $level = 0, array $context = []): void {}

View File

@@ -1,5 +1,5 @@
/* This is a generated file, edit the .stub.php file instead.
* Stub hash: cd534a8394f535a600bf45a333955d23b5154756 */
* Stub hash: 60f0d27c04f94d7b24c052e91ef294595a2bc421 */
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_handle_request, 0, 1, _IS_BOOL, 0)
ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0)
@@ -35,6 +35,12 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_mercure_publish, 0, 1, IS_STRING
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, retry, IS_LONG, 1, "null")
ZEND_END_ARG_INFO()
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_frankenphp_log, 0, 1, IS_VOID, 0)
ZEND_ARG_TYPE_INFO(0, message, IS_STRING, 0)
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, level, IS_LONG, 0, "0")
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, context, IS_ARRAY, 0, "[]")
ZEND_END_ARG_INFO()
ZEND_FUNCTION(frankenphp_handle_request);
ZEND_FUNCTION(headers_send);
@@ -42,6 +48,7 @@ ZEND_FUNCTION(frankenphp_finish_request);
ZEND_FUNCTION(frankenphp_request_headers);
ZEND_FUNCTION(frankenphp_response_headers);
ZEND_FUNCTION(mercure_publish);
ZEND_FUNCTION(frankenphp_log);
static const zend_function_entry ext_functions[] = {
@@ -55,5 +62,14 @@ static const zend_function_entry ext_functions[] = {
ZEND_FE(frankenphp_response_headers, arginfo_frankenphp_response_headers)
ZEND_FALIAS(apache_response_headers, frankenphp_response_headers, arginfo_apache_response_headers)
ZEND_FE(mercure_publish, arginfo_mercure_publish)
ZEND_FE(frankenphp_log, arginfo_frankenphp_log)
ZEND_FE_END
};
static void register_frankenphp_symbols(int module_number)
{
REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_DEBUG", -4, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_INFO", 0, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_WARN", 4, CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("FRANKENPHP_LOG_LEVEL_ERROR", 8, CONST_PERSISTENT);
}

View File

@@ -32,10 +32,6 @@ import (
"github.com/dunglas/frankenphp/internal/fastabs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/exp/zapslog"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest"
"go.uber.org/zap/zaptest/observer"
)
type testOptions struct {
@@ -62,10 +58,6 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *
cwd, _ := os.Getwd()
testDataDir := cwd + "/testdata/"
if opts.logger == nil {
opts.logger = slog.New(zapslog.NewHandler(zaptest.NewLogger(t).Core()))
}
initOpts := []frankenphp.Option{frankenphp.WithLogger(opts.logger)}
if opts.workerScript != "" {
workerOpts := []frankenphp.WorkerOption{
@@ -423,36 +415,61 @@ my_autoloader`, i), body)
}, opts)
}
func TestLog_module(t *testing.T) { testLog(t, &testOptions{}) }
func TestLog_worker(t *testing.T) {
testLog(t, &testOptions{workerScript: "log.php"})
func TestLog_error_log_module(t *testing.T) { testLog_error_log(t, &testOptions{}) }
func TestLog_error_log_worker(t *testing.T) {
testLog_error_log(t, &testOptions{workerScript: "log-error_log.php"})
}
func testLog(t *testing.T, opts *testOptions) {
logger, logs := observer.New(zapcore.InfoLevel)
opts.logger = slog.New(zapslog.NewHandler(logger))
func testLog_error_log(t *testing.T, opts *testOptions) {
var buf fmt.Stringer
opts.logger, buf = newTestLogger(t)
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/log.php?i=%d", i), nil)
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/log-error_log.php?i=%d", i), nil)
w := httptest.NewRecorder()
handler(w, req)
for logs.FilterMessage(fmt.Sprintf("request %d", i)).Len() <= 0 {
assert.Contains(t, buf.String(), fmt.Sprintf("request %d", i))
}, opts)
}
func TestLog_frankenphp_log_module(t *testing.T) { testLog_frankenphp_log(t, &testOptions{}) }
func TestLog_frankenphp_log_worker(t *testing.T) {
testLog_frankenphp_log(t, &testOptions{workerScript: "log-frankenphp_log.php"})
}
func testLog_frankenphp_log(t *testing.T, opts *testOptions) {
var buf fmt.Stringer
opts.logger, buf = newTestLogger(t)
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/log-frankenphp_log.php?i=%d", i), nil)
w := httptest.NewRecorder()
handler(w, req)
logs := buf.String()
for _, message := range []string{
`level=INFO msg="default level message"`,
fmt.Sprintf(`level=DEBUG msg="some debug message %d" "key int"=1`, i),
fmt.Sprintf(`level=INFO msg="some info message %d" "key string"=string`, i),
fmt.Sprintf(`level=WARN msg="some warn message %d"`, i),
fmt.Sprintf(`level=ERROR msg="some error message %d" err="[a v]"`, i),
} {
assert.Contains(t, logs, message)
}
}, opts)
}
func TestConnectionAbort_module(t *testing.T) { testConnectionAbort(t, &testOptions{}) }
func TestConnectionAbort_worker(t *testing.T) {
testConnectionAbort(t, &testOptions{workerScript: "connectionStatusLog.php"})
testConnectionAbort(t, &testOptions{workerScript: "connection_status.php"})
}
func testConnectionAbort(t *testing.T, opts *testOptions) {
testFinish := func(finish string) {
t.Run(fmt.Sprintf("finish=%s", finish), func(t *testing.T) {
logger, logs := observer.New(zapcore.InfoLevel)
opts.logger = slog.New(zapslog.NewHandler(logger))
var buf syncBuffer
opts.logger = slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/connectionStatusLog.php?i=%d&finish=%s", i, finish), nil)
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/connection_status.php?i=%d&finish=%s", i, finish), nil)
w := httptest.NewRecorder()
ctx, cancel := context.WithCancel(req.Context())
@@ -460,7 +477,7 @@ func testConnectionAbort(t *testing.T, opts *testOptions) {
cancel()
handler(w, req)
for logs.FilterMessage(fmt.Sprintf("request %d: 1", i)).Len() <= 0 {
for !strings.Contains(buf.String(), fmt.Sprintf("request %d: 1", i)) {
}
}, opts)
})
@@ -618,8 +635,9 @@ func testRequestHeaders(t *testing.T, opts *testOptions) {
}
func TestFailingWorker(t *testing.T) {
t.Cleanup(frankenphp.Shutdown)
err := frankenphp.Init(
frankenphp.WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
frankenphp.WithWorkers("failing worker", "testdata/failing-worker.php", 4, frankenphp.WithWorkerMaxFailures(1)),
frankenphp.WithNumThreads(5),
)
@@ -1057,7 +1075,6 @@ func FuzzRequest(f *testing.F) {
// Headers should always be present even if empty
assert.Contains(t, body, fmt.Sprintf("[CONTENT_TYPE] => %s", fuzzedString))
assert.Contains(t, body, fmt.Sprintf("[HTTP_FUZZED] => %s", fuzzedString))
}, &testOptions{workerScript: "request-headers.php"})
})
}

18
go.mod
View File

@@ -1,18 +1,17 @@
module github.com/dunglas/frankenphp
go 1.25.0
go 1.25.4
retract v1.0.0-rc.1 // Human error
require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/dunglas/mercure v0.21.2
github.com/dunglas/mercure v0.21.4
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146
github.com/maypok86/otter/v2 v2.2.1
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.1
go.uber.org/zap/exp v0.3.0
golang.org/x/net v0.47.0
golang.org/x/net v0.48.0
)
require (
@@ -57,12 +56,11 @@ require (
github.com/unrolled/secure v1.17.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

36
go.sum
View File

@@ -18,10 +18,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dunglas/mercure v0.21.2 h1:qaLTScSwsCHDps++4AeLWrRp3BysdR5EoHBqu7JNhas=
github.com/dunglas/mercure v0.21.2/go.mod h1:3ElA7VwRI8BHUIAVU8oGlvPaqGwsKU5zZVWFNSFg/+U=
github.com/dunglas/mercure v0.21.4 h1:mXPXHfB+4cYfFFCRRDY198mfef5+MQcdCpUnAHBUW2M=
github.com/dunglas/mercure v0.21.4/go.mod h1:l/dglCjp/OQx8/quRyceRPx2hqZQ3CNviwoLMRQiJ/k=
github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4=
github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w=
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 h1:h3vVM6X45PK0mAk8NqiYNQGXTyhvXy1HQ5GhuQN4eeA=
github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146/go.mod h1:sVUOkwtftoj71nnJRG2S0oWNfXFdKpz/M9vK0z06nmM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -102,28 +104,22 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

44
hotreload.go Normal file
View File

@@ -0,0 +1,44 @@
//go:build !nomercure && !nowatcher
package frankenphp
import (
"encoding/json"
"log/slog"
"github.com/dunglas/frankenphp/internal/watcher"
"github.com/dunglas/mercure"
watcherGo "github.com/e-dant/watcher/watcher-go"
)
// WithHotReload sets files to watch for file changes to trigger a hot reload update.
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) {
// Wait for workers to restart before sending the update
go func() {
data, err := json.Marshal(events)
if err != nil {
if globalLogger.Enabled(globalCtx, slog.LevelError) {
globalLogger.LogAttrs(globalCtx, slog.LevelError, "error marshaling watcher events", slog.Any("error", err))
}
return
}
if err := hub.Publish(globalCtx, &mercure.Update{
Topics: []string{topic},
Event: mercure.Event{Data: string(data)},
Debug: globalLogger.Enabled(globalCtx, slog.LevelDebug),
}); err != nil && globalLogger.Enabled(globalCtx, slog.LevelError) {
globalLogger.LogAttrs(globalCtx, slog.LevelError, "error publishing hot reloading Mercure update", slog.Any("error", err))
}
}()
},
})
return nil
}
}

View File

@@ -29,7 +29,7 @@ func (ag *arginfoGenerator) generate() error {
output, err := cmd.CombinedOutput()
if err != nil {
log.Print("gen_stub.php output:\n", string(output))
return fmt.Errorf("running gen_stub script: %w", err)
return fmt.Errorf("running gen_stub script: %w\nOutput: %s", err, string(output))
}
return ag.fixArginfoFile(stubFile)

View File

@@ -128,14 +128,15 @@ type GoParameter struct {
Type string
}
var phpToGoTypeMap = map[phpType]string{
phpString: "string",
phpInt: "int64",
phpFloat: "float64",
phpBool: "bool",
phpArray: "*frankenphp.Array",
phpMixed: "any",
phpVoid: "",
var phpToGoTypeMap= map[phpType]string{
phpString: "string",
phpInt: "int64",
phpFloat: "float64",
phpBool: "bool",
phpArray: "*frankenphp.Array",
phpMixed: "any",
phpVoid: "",
phpCallable: "*C.zval",
}
func (gg *GoFileGenerator) phpTypeToGoType(phpT phpType) string {

View File

@@ -703,6 +703,125 @@ func testGoFileExportedFunctions(t *testing.T, content string, functions []phpFu
}
}
func TestGoFileGenerator_MethodWrapperWithCallableParams(t *testing.T) {
tmpDir := t.TempDir()
sourceContent := `package main
import "C"
//export_php:class CallableClass
type CallableStruct struct{}
//export_php:method CallableClass::processCallback(callable $callback): string
func (cs *CallableStruct) ProcessCallback(callback *C.zval) string {
return "processed"
}
//export_php:method CallableClass::processOptionalCallback(?callable $callback): string
func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string {
return "processed_optional"
}`
sourceFile := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644))
methods := []phpClassMethod{
{
Name: "ProcessCallback",
PhpName: "processCallback",
ClassName: "CallableClass",
Signature: "processCallback(callable $callback): string",
ReturnType: phpString,
Params: []phpParameter{
{Name: "callback", PhpType: phpCallable, IsNullable: false},
},
GoFunction: `func (cs *CallableStruct) ProcessCallback(callback *C.zval) string {
return "processed"
}`,
},
{
Name: "ProcessOptionalCallback",
PhpName: "processOptionalCallback",
ClassName: "CallableClass",
Signature: "processOptionalCallback(?callable $callback): string",
ReturnType: phpString,
Params: []phpParameter{
{Name: "callback", PhpType: phpCallable, IsNullable: true},
},
GoFunction: `func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string {
return "processed_optional"
}`,
},
}
classes := []phpClass{
{
Name: "CallableClass",
GoStruct: "CallableStruct",
Methods: methods,
},
}
generator := &Generator{
BaseName: "callable_test",
SourceFile: sourceFile,
Classes: classes,
BuildDir: tmpDir,
}
goGen := GoFileGenerator{generator}
content, err := goGen.buildContent()
require.NoError(t, err)
expectedCallableWrapperSignature := "func ProcessCallback_wrapper(handle C.uintptr_t, callback *C.zval) unsafe.Pointer"
assert.Contains(t, content, expectedCallableWrapperSignature, "Generated content should contain callable wrapper signature: %s", expectedCallableWrapperSignature)
expectedOptionalCallableWrapperSignature := "func ProcessOptionalCallback_wrapper(handle C.uintptr_t, callback *C.zval) unsafe.Pointer"
assert.Contains(t, content, expectedOptionalCallableWrapperSignature, "Generated content should contain optional callable wrapper signature: %s", expectedOptionalCallableWrapperSignature)
expectedCallableCall := "structObj.ProcessCallback(callback)"
assert.Contains(t, content, expectedCallableCall, "Generated content should contain callable method call: %s", expectedCallableCall)
expectedOptionalCallableCall := "structObj.ProcessOptionalCallback(callback)"
assert.Contains(t, content, expectedOptionalCallableCall, "Generated content should contain optional callable method call: %s", expectedOptionalCallableCall)
assert.Contains(t, content, "//export ProcessCallback_wrapper", "Generated content should contain ProcessCallback export directive")
assert.Contains(t, content, "//export ProcessOptionalCallback_wrapper", "Generated content should contain ProcessOptionalCallback export directive")
}
func TestGoFileGenerator_phpTypeToGoType(t *testing.T) {
generator := &Generator{}
goGen := GoFileGenerator{generator}
tests := []struct {
phpType phpType
expected string
}{
{phpString, "string"},
{phpInt, "int64"},
{phpFloat, "float64"},
{phpBool, "bool"},
{phpArray, "*frankenphp.Array"},
{phpMixed, "any"},
{phpVoid, ""},
{phpCallable, "*C.zval"},
}
for _, tt := range tests {
t.Run(string(tt.phpType), func(t *testing.T) {
result := goGen.phpTypeToGoType(tt.phpType)
assert.Equal(t, tt.expected, result, "phpTypeToGoType(%s) should return %s", tt.phpType, tt.expected)
})
}
t.Run("unknown_type", func(t *testing.T) {
unknownType := phpType("unknown")
result := goGen.phpTypeToGoType(unknownType)
assert.Equal(t, "any", result, "phpTypeToGoType should fallback to interface{} for unknown types")
})
}
func testGoFileInternalFunctions(t *testing.T, content string) {
internalIndicators := []string{
"func internalHelper",

View File

@@ -0,0 +1,793 @@
//go:build integration
package extgen
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testModuleName = "github.com/frankenphp/test-extension"
)
type IntegrationTestSuite struct {
tempDir string
genStubScript string
xcaddyPath string
frankenphpPath string
phpConfigPath string
t *testing.T
}
func setupTest(t *testing.T) *IntegrationTestSuite {
t.Helper()
suite := &IntegrationTestSuite{t: t}
suite.genStubScript = os.Getenv("GEN_STUB_SCRIPT")
if suite.genStubScript == "" {
suite.genStubScript = "/usr/local/src/php/build/gen_stub.php"
}
if _, err := os.Stat(suite.genStubScript); os.IsNotExist(err) {
t.Error("GEN_STUB_SCRIPT not found. Integration tests require PHP sources. Set GEN_STUB_SCRIPT environment variable.")
}
xcaddyPath, err := exec.LookPath("xcaddy")
if err != nil {
t.Error("xcaddy not found in PATH. Integration tests require xcaddy to build FrankenPHP.")
}
suite.xcaddyPath = xcaddyPath
phpConfigPath, err := exec.LookPath("php-config")
if err != nil {
t.Error("php-config not found in PATH. Integration tests require PHP development headers.")
}
suite.phpConfigPath = phpConfigPath
tempDir := t.TempDir()
suite.tempDir = tempDir
return suite
}
func (s *IntegrationTestSuite) createGoModule(sourceFile string) (string, error) {
s.t.Helper()
moduleDir := filepath.Join(s.tempDir, "module")
if err := os.MkdirAll(moduleDir, 0o755); err != nil {
return "", fmt.Errorf("failed to create module directory: %w", err)
}
// Get project root for replace directive
projectRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
return "", fmt.Errorf("failed to get project root: %w", err)
}
goModContent := fmt.Sprintf(`module %s
go 1.25
require github.com/dunglas/frankenphp v0.0.0
replace github.com/dunglas/frankenphp => %s
`, testModuleName, projectRoot)
if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
return "", fmt.Errorf("failed to create go.mod: %w", err)
}
sourceContent, err := os.ReadFile(sourceFile)
if err != nil {
return "", fmt.Errorf("failed to read source file: %w", err)
}
targetFile := filepath.Join(moduleDir, filepath.Base(sourceFile))
if err := os.WriteFile(targetFile, sourceContent, 0o644); err != nil {
return "", fmt.Errorf("failed to write source file: %w", err)
}
return targetFile, nil
}
func (s *IntegrationTestSuite) runExtensionInit(sourceFile string) error {
s.t.Helper()
os.Setenv("GEN_STUB_SCRIPT", s.genStubScript)
baseName := SanitizePackageName(strings.TrimSuffix(filepath.Base(sourceFile), ".go"))
generator := Generator{
BaseName: baseName,
SourceFile: sourceFile,
BuildDir: filepath.Dir(sourceFile),
}
if err := generator.Generate(); err != nil {
return fmt.Errorf("generation failed: %w", err)
}
return nil
}
// cleanupGeneratedFiles removes generated files from the original source directory
func (s *IntegrationTestSuite) cleanupGeneratedFiles(originalSourceFile string) {
s.t.Helper()
sourceDir := filepath.Dir(originalSourceFile)
baseName := SanitizePackageName(strings.TrimSuffix(filepath.Base(originalSourceFile), ".go"))
generatedFiles := []string{
baseName + ".stub.php",
baseName + "_arginfo.h",
baseName + ".h",
baseName + ".c",
baseName + ".go",
"README.md",
}
for _, file := range generatedFiles {
fullPath := filepath.Join(sourceDir, file)
if _, err := os.Stat(fullPath); err == nil {
os.Remove(fullPath)
}
}
}
// compileFrankenPHP compiles FrankenPHP with the generated extension
func (s *IntegrationTestSuite) compileFrankenPHP(moduleDir string) (string, error) {
s.t.Helper()
projectRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
return "", fmt.Errorf("failed to get project root: %w", err)
}
cflags, err := exec.Command(s.phpConfigPath, "--includes").Output()
if err != nil {
return "", fmt.Errorf("failed to get PHP includes: %w", err)
}
ldflags, err := exec.Command(s.phpConfigPath, "--ldflags").Output()
if err != nil {
return "", fmt.Errorf("failed to get PHP ldflags: %w", err)
}
libs, err := exec.Command(s.phpConfigPath, "--libs").Output()
if err != nil {
return "", fmt.Errorf("failed to get PHP libs: %w", err)
}
cgoCflags := strings.TrimSpace(string(cflags))
cgoLdflags := strings.TrimSpace(string(ldflags)) + " " + strings.TrimSpace(string(libs))
outputBinary := filepath.Join(s.tempDir, "frankenphp")
cmd := exec.Command(
s.xcaddyPath,
"build",
"--output", outputBinary,
"--with", "github.com/dunglas/frankenphp="+projectRoot,
"--with", "github.com/dunglas/frankenphp/caddy="+projectRoot+"/caddy",
"--with", testModuleName+"="+moduleDir,
)
cmd.Env = append(os.Environ(),
"CGO_ENABLED=1",
"CGO_CFLAGS="+cgoCflags,
"CGO_LDFLAGS="+cgoLdflags,
fmt.Sprintf("XCADDY_GO_BUILD_FLAGS=-ldflags='-w -s' -tags=nobadger,nomysql,nopgx,nowatcher"),
)
cmd.Dir = s.tempDir
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("xcaddy build failed: %w\nOutput: %s", err, string(output))
}
s.frankenphpPath = outputBinary
return outputBinary, nil
}
func (s *IntegrationTestSuite) runPHPCode(phpCode string) (string, error) {
s.t.Helper()
if s.frankenphpPath == "" {
return "", fmt.Errorf("FrankenPHP not compiled yet")
}
phpFile := filepath.Join(s.tempDir, "test.php")
if err := os.WriteFile(phpFile, []byte(phpCode), 0o644); err != nil {
return "", fmt.Errorf("failed to create PHP file: %w", err)
}
cmd := exec.Command(s.frankenphpPath, "php-cli", phpFile)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("PHP execution failed: %w\nOutput: %s", err, string(output))
}
return string(output), nil
}
// verifyPHPSymbols checks if PHP can find the exposed functions, classes, and constants
func (s *IntegrationTestSuite) verifyPHPSymbols(functions []string, classes []string, constants []string) error {
s.t.Helper()
var checks []string
for _, fn := range functions {
checks = append(checks, fmt.Sprintf("if (!function_exists('%s')) { echo 'MISSING_FUNCTION: %s'; exit(1); }", fn, fn))
}
for _, cls := range classes {
checks = append(checks, fmt.Sprintf("if (!class_exists('%s')) { echo 'MISSING_CLASS: %s'; exit(1); }", cls, cls))
}
for _, cnst := range constants {
checks = append(checks, fmt.Sprintf("if (!defined('%s')) { echo 'MISSING_CONSTANT: %s'; exit(1); }", cnst, cnst))
}
checks = append(checks, "echo 'OK';")
phpCode := "<?php\n" + strings.Join(checks, "\n")
output, err := s.runPHPCode(phpCode)
if err != nil {
return err
}
if !strings.Contains(output, "OK") {
return fmt.Errorf("symbol verification failed: %s", output)
}
return nil
}
func (s *IntegrationTestSuite) verifyFunctionBehavior(phpCode string, expectedOutput string) error {
s.t.Helper()
output, err := s.runPHPCode(phpCode)
if err != nil {
return err
}
if !strings.Contains(output, expectedOutput) {
return fmt.Errorf("unexpected output.\nExpected to contain: %q\nGot: %q", expectedOutput, output)
}
return nil
}
func TestBasicFunction(t *testing.T) {
suite := setupTest(t)
sourceFile := filepath.Join("..", "..", "testdata", "integration", "basic_function.go")
sourceFile, err := filepath.Abs(sourceFile)
require.NoError(t, err)
defer suite.cleanupGeneratedFiles(sourceFile)
targetFile, err := suite.createGoModule(sourceFile)
require.NoError(t, err)
err = suite.runExtensionInit(targetFile)
require.NoError(t, err, "extension-init should succeed")
baseDir := filepath.Dir(targetFile)
baseName := strings.TrimSuffix(filepath.Base(targetFile), ".go")
expectedFiles := []string{
baseName + ".stub.php",
baseName + "_arginfo.h",
baseName + ".h",
baseName + ".c",
baseName + ".go",
"README.md",
}
for _, file := range expectedFiles {
fullPath := filepath.Join(baseDir, file)
assert.FileExists(t, fullPath, "Generated file should exist: %s", file)
}
_, err = suite.compileFrankenPHP(filepath.Dir(targetFile))
require.NoError(t, err, "FrankenPHP compilation should succeed")
err = suite.verifyPHPSymbols(
[]string{"test_uppercase", "test_add_numbers", "test_multiply", "test_is_enabled"},
[]string{},
[]string{},
)
require.NoError(t, err, "all functions should be accessible from PHP")
err = suite.verifyFunctionBehavior(`<?php
$result = test_uppercase("hello world");
if ($result !== "HELLO WORLD") {
echo "FAIL: test_uppercase expected 'HELLO WORLD', got '$result'";
exit(1);
}
$result = test_uppercase("");
if ($result !== "") {
echo "FAIL: test_uppercase with empty string expected '', got '$result'";
exit(1);
}
$sum = test_add_numbers(5, 7);
if ($sum !== 12) {
echo "FAIL: test_add_numbers(5, 7) expected 12, got $sum";
exit(1);
}
$result = test_is_enabled(true);
if ($result !== false) {
echo "FAIL: test_is_enabled(true) expected false, got " . ($result ? "true" : "false");
exit(1);
}
$result = test_is_enabled(false);
if ($result !== true) {
echo "FAIL: test_is_enabled(false) expected true, got " . ($result ? "true" : "false");
exit(1);
}
echo "OK";
`, "OK")
require.NoError(t, err, "all function calls should work correctly")
}
func TestClassMethodsIntegration(t *testing.T) {
suite := setupTest(t)
sourceFile := filepath.Join("..", "..", "testdata", "integration", "class_methods.go")
sourceFile, err := filepath.Abs(sourceFile)
require.NoError(t, err)
defer suite.cleanupGeneratedFiles(sourceFile)
targetFile, err := suite.createGoModule(sourceFile)
require.NoError(t, err)
err = suite.runExtensionInit(targetFile)
require.NoError(t, err)
_, err = suite.compileFrankenPHP(filepath.Dir(targetFile))
require.NoError(t, err)
err = suite.verifyPHPSymbols(
[]string{},
[]string{"Counter", "StringHolder"},
[]string{},
)
require.NoError(t, err, "all classes should be accessible from PHP")
err = suite.verifyFunctionBehavior(`<?php
$counter = new Counter();
if ($counter->getValue() !== 0) {
echo "FAIL: Counter initial value expected 0, got " . $counter->getValue();
exit(1);
}
$counter->increment();
if ($counter->getValue() !== 1) {
echo "FAIL: Counter after increment expected 1, got " . $counter->getValue();
exit(1);
}
$counter->decrement();
if ($counter->getValue() !== 0) {
echo "FAIL: Counter after decrement expected 0, got " . $counter->getValue();
exit(1);
}
$counter->setValue(10);
if ($counter->getValue() !== 10) {
echo "FAIL: Counter after setValue(10) expected 10, got " . $counter->getValue();
exit(1);
}
$newValue = $counter->addValue(5);
if ($newValue !== 15) {
echo "FAIL: Counter addValue(5) expected to return 15, got $newValue";
exit(1);
}
if ($counter->getValue() !== 15) {
echo "FAIL: Counter value after addValue(5) expected 15, got " . $counter->getValue();
exit(1);
}
$counter->updateWithNullable(50);
if ($counter->getValue() !== 50) {
echo "FAIL: Counter after updateWithNullable(50) expected 50, got " . $counter->getValue();
exit(1);
}
$counter->updateWithNullable(null);
if ($counter->getValue() !== 50) {
echo "FAIL: Counter after updateWithNullable(null) expected 50 (unchanged), got " . $counter->getValue();
exit(1);
}
$counter->reset();
if ($counter->getValue() !== 0) {
echo "FAIL: Counter after reset expected 0, got " . $counter->getValue();
exit(1);
}
$counter1 = new Counter();
$counter2 = new Counter();
$counter1->setValue(100);
$counter2->setValue(200);
if ($counter1->getValue() !== 100 || $counter2->getValue() !== 200) {
echo "FAIL: Multiple Counter instances should be independent";
exit(1);
}
$holder = new StringHolder();
$holder->setData("test string");
if ($holder->getData() !== "test string") {
echo "FAIL: StringHolder getData expected 'test string', got '" . $holder->getData() . "'";
exit(1);
}
if ($holder->getLength() !== 11) {
echo "FAIL: StringHolder getLength expected 11, got " . $holder->getLength();
exit(1);
}
$holder->setData("");
if ($holder->getData() !== "") {
echo "FAIL: StringHolder empty string expected '', got '" . $holder->getData() . "'";
exit(1);
}
if ($holder->getLength() !== 0) {
echo "FAIL: StringHolder empty string length expected 0, got " . $holder->getLength();
exit(1);
}
echo "OK";
`, "OK")
require.NoError(t, err, "all class methods should work correctly")
}
func TestConstants(t *testing.T) {
suite := setupTest(t)
sourceFile := filepath.Join("..", "..", "testdata", "integration", "constants.go")
sourceFile, err := filepath.Abs(sourceFile)
require.NoError(t, err)
defer suite.cleanupGeneratedFiles(sourceFile)
targetFile, err := suite.createGoModule(sourceFile)
require.NoError(t, err)
err = suite.runExtensionInit(targetFile)
require.NoError(t, err)
_, err = suite.compileFrankenPHP(filepath.Dir(targetFile))
require.NoError(t, err)
err = suite.verifyPHPSymbols(
[]string{"test_with_constants"},
[]string{"Config"},
[]string{
"TEST_MAX_RETRIES", "TEST_API_VERSION", "TEST_ENABLED", "TEST_PI",
"STATUS_PENDING", "STATUS_PROCESSING", "STATUS_COMPLETED",
},
)
require.NoError(t, err, "all constants, functions, and classes should be accessible from PHP")
err = suite.verifyFunctionBehavior(`<?php
if (TEST_MAX_RETRIES !== 100) {
echo "FAIL: TEST_MAX_RETRIES expected 100, got " . TEST_MAX_RETRIES;
exit(1);
}
if (TEST_API_VERSION !== "2.0.0") {
echo "FAIL: TEST_API_VERSION expected '2.0.0', got '" . TEST_API_VERSION . "'";
exit(1);
}
if (TEST_ENABLED !== true) {
var_dump(TEST_ENABLED);
echo "FAIL: TEST_ENABLED expected true, got " . (TEST_ENABLED ? "true" : "false");
exit(1);
}
if (abs(TEST_PI - 3.14159) > 0.00001) {
echo "FAIL: TEST_PI expected 3.14159, got " . TEST_PI;
exit(1);
}
if (Config::MODE_DEBUG !== 1) {
echo "FAIL: Config::MODE_DEBUG expected 1, got " . Config::MODE_DEBUG;
exit(1);
}
if (Config::MODE_PRODUCTION !== 2) {
echo "FAIL: Config::MODE_PRODUCTION expected 2, got " . Config::MODE_PRODUCTION;
exit(1);
}
if (Config::DEFAULT_TIMEOUT !== 30) {
echo "FAIL: Config::DEFAULT_TIMEOUT expected 30, got " . Config::DEFAULT_TIMEOUT;
exit(1);
}
$config = new Config();
$config->setMode(Config::MODE_DEBUG);
if ($config->getMode() !== Config::MODE_DEBUG) {
echo "FAIL: Config getMode expected MODE_DEBUG, got " . $config->getMode();
exit(1);
}
$result = test_with_constants(STATUS_PENDING);
if ($result !== "pending") {
echo "FAIL: test_with_constants(STATUS_PENDING) expected 'pending', got '$result'";
exit(1);
}
$result = test_with_constants(STATUS_PROCESSING);
if ($result !== "processing") {
echo "FAIL: test_with_constants(STATUS_PROCESSING) expected 'processing', got '$result'";
exit(1);
}
$result = test_with_constants(STATUS_COMPLETED);
if ($result !== "completed") {
echo "FAIL: test_with_constants(STATUS_COMPLETED) expected 'completed', got '$result'";
exit(1);
}
$result = test_with_constants(999);
if ($result !== "unknown") {
echo "FAIL: test_with_constants(999) expected 'unknown', got '$result'";
exit(1);
}
echo "OK";
`, "OK")
require.NoError(t, err, "all constants should have correct values and functions should work")
}
func TestNamespace(t *testing.T) {
suite := setupTest(t)
sourceFile := filepath.Join("..", "..", "testdata", "integration", "namespace.go")
sourceFile, err := filepath.Abs(sourceFile)
require.NoError(t, err)
defer suite.cleanupGeneratedFiles(sourceFile)
targetFile, err := suite.createGoModule(sourceFile)
require.NoError(t, err)
err = suite.runExtensionInit(targetFile)
require.NoError(t, err)
_, err = suite.compileFrankenPHP(filepath.Dir(targetFile))
require.NoError(t, err)
err = suite.verifyPHPSymbols(
[]string{`\\TestIntegration\\Extension\\greet`},
[]string{`\\TestIntegration\\Extension\\Person`},
[]string{`\\TestIntegration\\Extension\\NAMESPACE_VERSION`},
)
require.NoError(t, err, "all namespaced symbols should be accessible from PHP")
err = suite.verifyFunctionBehavior(`<?php
use TestIntegration\Extension;
if (Extension\NAMESPACE_VERSION !== "1.0.0") {
echo "FAIL: NAMESPACE_VERSION expected '1.0.0', got '" . Extension\NAMESPACE_VERSION . "'";
exit(1);
}
$greeting = Extension\greet("Alice");
if ($greeting !== "Hello, Alice!") {
echo "FAIL: greet('Alice') expected 'Hello, Alice!', got '$greeting'";
exit(1);
}
$greeting = Extension\greet("");
if ($greeting !== "Hello, !") {
echo "FAIL: greet('') expected 'Hello, !', got '$greeting'";
exit(1);
}
if (Extension\Person::DEFAULT_AGE !== 18) {
echo "FAIL: Person::DEFAULT_AGE expected 18, got " . Extension\Person::DEFAULT_AGE;
exit(1);
}
$person = new Extension\Person();
$person->setName("Bob");
$person->setAge(25);
if ($person->getName() !== "Bob") {
echo "FAIL: Person getName expected 'Bob', got '" . $person->getName() . "'";
exit(1);
}
if ($person->getAge() !== 25) {
echo "FAIL: Person getAge expected 25, got " . $person->getAge();
exit(1);
}
$person->setAge(Extension\Person::DEFAULT_AGE);
if ($person->getAge() !== 18) {
echo "FAIL: Person setAge(DEFAULT_AGE) expected 18, got " . $person->getAge();
exit(1);
}
$person1 = new Extension\Person();
$person2 = new Extension\Person();
$person1->setName("Alice");
$person1->setAge(30);
$person2->setName("Charlie");
$person2->setAge(40);
if ($person1->getName() !== "Alice" || $person1->getAge() !== 30) {
echo "FAIL: person1 should have independent state";
exit(1);
}
if ($person2->getName() !== "Charlie" || $person2->getAge() !== 40) {
echo "FAIL: person2 should have independent state";
exit(1);
}
echo "OK";
`, "OK")
require.NoError(t, err, "all namespaced symbols should work correctly")
}
func TestInvalidSignature(t *testing.T) {
suite := setupTest(t)
sourceFile := filepath.Join("..", "..", "testdata", "integration", "invalid_signature.go")
sourceFile, err := filepath.Abs(sourceFile)
require.NoError(t, err)
defer suite.cleanupGeneratedFiles(sourceFile)
targetFile, err := suite.createGoModule(sourceFile)
require.NoError(t, err)
err = suite.runExtensionInit(targetFile)
assert.Error(t, err, "extension-init should fail for invalid return type")
assert.Contains(t, err.Error(), "no PHP functions, classes, or constants found", "invalid functions should be ignored, resulting in no valid exports")
}
func TestTypeMismatch(t *testing.T) {
suite := setupTest(t)
sourceFile := filepath.Join("..", "..", "testdata", "integration", "type_mismatch.go")
sourceFile, err := filepath.Abs(sourceFile)
require.NoError(t, err)
defer suite.cleanupGeneratedFiles(sourceFile)
targetFile, err := suite.createGoModule(sourceFile)
require.NoError(t, err)
err = suite.runExtensionInit(targetFile)
assert.NoError(t, err, "generation should succeed - class is valid even though function/method have type mismatches")
baseDir := filepath.Dir(targetFile)
baseName := strings.TrimSuffix(filepath.Base(targetFile), ".go")
stubFile := filepath.Join(baseDir, baseName+".stub.php")
assert.FileExists(t, stubFile, "stub file should be generated for valid class")
}
func TestMissingGenStub(t *testing.T) {
// temp override of GEN_STUB_SCRIPT
originalValue := os.Getenv("GEN_STUB_SCRIPT")
defer os.Setenv("GEN_STUB_SCRIPT", originalValue)
os.Setenv("GEN_STUB_SCRIPT", "/nonexistent/gen_stub.php")
tempDir := t.TempDir()
sourceFile := filepath.Join(tempDir, "test.go")
err := os.WriteFile(sourceFile, []byte(`package test
//export_php:function dummy(): void
func dummy() {}
`), 0o644)
require.NoError(t, err)
baseName := SanitizePackageName(strings.TrimSuffix(filepath.Base(sourceFile), ".go"))
gen := Generator{
BaseName: baseName,
SourceFile: sourceFile,
BuildDir: filepath.Dir(sourceFile),
}
err = gen.Generate()
assert.Error(t, err, "should fail when gen_stub.php is missing")
assert.Contains(t, err.Error(), "gen_stub.php", "error should mention missing script")
}
func TestCallable(t *testing.T) {
suite := setupTest(t)
sourceFile := filepath.Join("..", "..", "testdata", "integration", "callable.go")
sourceFile, err := filepath.Abs(sourceFile)
require.NoError(t, err)
defer suite.cleanupGeneratedFiles(sourceFile)
targetFile, err := suite.createGoModule(sourceFile)
require.NoError(t, err)
err = suite.runExtensionInit(targetFile)
require.NoError(t, err)
_, err = suite.compileFrankenPHP(filepath.Dir(targetFile))
require.NoError(t, err)
err = suite.verifyPHPSymbols(
[]string{"my_array_map", "my_filter"},
[]string{"Processor"},
[]string{},
)
require.NoError(t, err, "all functions and classes should be accessible from PHP")
err = suite.verifyFunctionBehavior(`<?php
$result = my_array_map([1, 2, 3], function($x) { return $x * 2; });
if ($result !== [2, 4, 6]) {
echo "FAIL: my_array_map with closure expected [2, 4, 6], got " . json_encode($result);
exit(1);
}
$result = my_array_map(['hello', 'world'], 'strtoupper');
if ($result !== ['HELLO', 'WORLD']) {
echo "FAIL: my_array_map with function name expected ['HELLO', 'WORLD'], got " . json_encode($result);
exit(1);
}
$result = my_array_map([], function($x) { return $x; });
if ($result !== []) {
echo "FAIL: my_array_map with empty array expected [], got " . json_encode($result);
exit(1);
}
$result = my_filter([1, 2, 3, 4, 5, 6], function($x) { return $x % 2 === 0; });
if ($result !== [2, 4, 6]) {
echo "FAIL: my_filter expected [2, 4, 6], got " . json_encode($result);
exit(1);
}
$result = my_filter([1, 2, 3, 4], null);
if ($result !== [1, 2, 3, 4]) {
echo "FAIL: my_filter with null callback expected [1, 2, 3, 4], got " . json_encode($result);
exit(1);
}
$processor = new Processor();
$result = $processor->transform('hello', function($s) { return strtoupper($s); });
if ($result !== 'HELLO') {
echo "FAIL: Processor::transform with closure expected 'HELLO', got '$result'";
exit(1);
}
$result = $processor->transform('world', 'strtoupper');
if ($result !== 'WORLD') {
echo "FAIL: Processor::transform with function name expected 'WORLD', got '$result'";
exit(1);
}
$result = $processor->transform(' test ', 'trim');
if ($result !== 'test') {
echo "FAIL: Processor::transform with trim expected 'test', got '$result'";
exit(1);
}
echo "OK";
`, "OK")
require.NoError(t, err, "all callable tests should pass")
}

View File

@@ -9,17 +9,18 @@ import (
type phpType string
const (
phpString phpType = "string"
phpInt phpType = "int"
phpFloat phpType = "float"
phpBool phpType = "bool"
phpArray phpType = "array"
phpObject phpType = "object"
phpMixed phpType = "mixed"
phpVoid phpType = "void"
phpNull phpType = "null"
phpTrue phpType = "true"
phpFalse phpType = "false"
phpString phpType = "string"
phpInt phpType = "int"
phpFloat phpType = "float"
phpBool phpType = "bool"
phpArray phpType = "array"
phpObject phpType = "object"
phpMixed phpType = "mixed"
phpVoid phpType = "void"
phpNull phpType = "null"
phpTrue phpType = "true"
phpFalse phpType = "false"
phpCallable phpType = "callable"
)
type phpFunction struct {

View File

@@ -68,8 +68,12 @@ func (pp *ParameterParser) generateSingleParamDeclaration(param phpParameter) []
if param.IsNullable {
decls = append(decls, fmt.Sprintf("zend_bool %s_is_null = 0;", param.Name))
}
case phpArray, phpMixed:
case phpArray:
decls = append(decls, fmt.Sprintf("zend_array *%s = NULL;", param.Name))
case phpMixed:
decls = append(decls, fmt.Sprintf("zval *%s = NULL;", param.Name))
case "callable":
decls = append(decls, fmt.Sprintf("zval *%s_callback;", param.Name))
}
return decls
@@ -118,9 +122,11 @@ func (pp *ParameterParser) generateParamParsingMacro(param phpParameter) string
case phpBool:
return fmt.Sprintf("\n Z_PARAM_BOOL_OR_NULL(%s, %s_is_null)", param.Name, param.Name)
case phpArray:
return fmt.Sprintf("\n Z_PARAM_ARRAY_OR_NULL(%s)", param.Name)
return fmt.Sprintf("\n Z_PARAM_ARRAY_HT_OR_NULL(%s)", param.Name)
case phpMixed:
return fmt.Sprintf("\n Z_PARAM_ZVAL_OR_NULL(%s)", param.Name)
case phpCallable:
return fmt.Sprintf("\n Z_PARAM_ZVAL_OR_NULL(%s_callback)", param.Name)
default:
return ""
}
@@ -135,9 +141,11 @@ func (pp *ParameterParser) generateParamParsingMacro(param phpParameter) string
case phpBool:
return fmt.Sprintf("\n Z_PARAM_BOOL(%s)", param.Name)
case phpArray:
return fmt.Sprintf("\n Z_PARAM_ARRAY(%s)", param.Name)
return fmt.Sprintf("\n Z_PARAM_ARRAY_HT(%s)", param.Name)
case phpMixed:
return fmt.Sprintf("\n Z_PARAM_ZVAL(%s)", param.Name)
case phpCallable:
return fmt.Sprintf("\n Z_PARAM_ZVAL(%s_callback)", param.Name)
default:
return ""
}
@@ -168,6 +176,8 @@ func (pp *ParameterParser) generateSingleGoCallParam(param phpParameter) string
return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name)
case phpBool:
return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name)
case phpCallable:
return fmt.Sprintf("%s_callback", param.Name)
default:
return param.Name
}
@@ -180,6 +190,8 @@ func (pp *ParameterParser) generateSingleGoCallParam(param phpParameter) string
return fmt.Sprintf("(double) %s", param.Name)
case phpBool:
return fmt.Sprintf("(int) %s", param.Name)
case phpCallable:
return fmt.Sprintf("%s_callback", param.Name)
default:
return param.Name
}

View File

@@ -145,14 +145,14 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) {
params: []phpParameter{
{Name: "items", PhpType: phpArray, HasDefault: false},
},
expected: " zval *items = NULL;",
expected: " zend_array *items = NULL;",
},
{
name: "nullable array parameter",
params: []phpParameter{
{Name: "items", PhpType: phpArray, HasDefault: false, IsNullable: true},
},
expected: " zval *items = NULL;",
expected: " zend_array *items = NULL;",
},
{
name: "mixed types with array",
@@ -161,7 +161,7 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) {
{Name: "items", PhpType: phpArray, HasDefault: false},
{Name: "count", PhpType: phpInt, HasDefault: true, DefaultValue: "5"},
},
expected: " zend_string *name = NULL;\n zval *items = NULL;\n zend_long count = 5;",
expected: " zend_string *name = NULL;\n zend_array *items = NULL;\n zend_long count = 5;",
},
{
name: "mixed parameter",
@@ -177,6 +177,29 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) {
},
expected: " zval *m = NULL;",
},
{
name: "callable parameter",
params: []phpParameter{
{Name: "callback", PhpType: phpCallable, HasDefault: false},
},
expected: " zval *callback_callback;",
},
{
name: "nullable callable parameter",
params: []phpParameter{
{Name: "callback", PhpType: phpCallable, HasDefault: false, IsNullable: true},
},
expected: " zval *callback_callback;",
},
{
name: "mixed types with callable",
params: []phpParameter{
{Name: "data", PhpType: phpArray, HasDefault: false},
{Name: "callback", PhpType: phpCallable, HasDefault: false},
{Name: "options", PhpType: phpInt, HasDefault: true, DefaultValue: "0"},
},
expected: " zend_array *data = NULL;\n zval *callback_callback;\n zend_long options = 0;",
},
}
for _, tt := range tests {
@@ -292,6 +315,29 @@ func TestParameterParser_GenerateGoCallParams(t *testing.T) {
},
expected: "name, items, (long) count",
},
{
name: "callable parameter",
params: []phpParameter{
{Name: "callback", PhpType: "callable"},
},
expected: "callback_callback",
},
{
name: "nullable callable parameter",
params: []phpParameter{
{Name: "callback", PhpType: "callable", IsNullable: true},
},
expected: "callback_callback",
},
{
name: "mixed parameters with callable",
params: []phpParameter{
{Name: "data", PhpType: "array"},
{Name: "callback", PhpType: "callable"},
{Name: "limit", PhpType: "int"},
},
expected: "data, callback_callback, (long) limit",
},
}
for _, tt := range tests {
@@ -353,12 +399,12 @@ func TestParameterParser_GenerateParamParsingMacro(t *testing.T) {
{
name: "array parameter",
param: phpParameter{Name: "items", PhpType: phpArray},
expected: "\n Z_PARAM_ARRAY(items)",
expected: "\n Z_PARAM_ARRAY_HT(items)",
},
{
name: "nullable array parameter",
param: phpParameter{Name: "items", PhpType: phpArray, IsNullable: true},
expected: "\n Z_PARAM_ARRAY_OR_NULL(items)",
expected: "\n Z_PARAM_ARRAY_HT_OR_NULL(items)",
},
{
name: "mixed parameter",
@@ -370,6 +416,16 @@ func TestParameterParser_GenerateParamParsingMacro(t *testing.T) {
param: phpParameter{Name: "m", PhpType: phpMixed, IsNullable: true},
expected: "\n Z_PARAM_ZVAL_OR_NULL(m)",
},
{
name: "callable parameter",
param: phpParameter{Name: "callback", PhpType: phpCallable},
expected: "\n Z_PARAM_ZVAL(callback_callback)",
},
{
name: "nullable callable parameter",
param: phpParameter{Name: "callback", PhpType: phpCallable, IsNullable: true},
expected: "\n Z_PARAM_ZVAL_OR_NULL(callback_callback)",
},
{
name: "unknown type",
param: phpParameter{Name: "unknown", PhpType: phpType("unknown")},
@@ -480,6 +536,16 @@ func TestParameterParser_GenerateSingleGoCallParam(t *testing.T) {
param: phpParameter{Name: "items", PhpType: phpArray, IsNullable: true},
expected: "items",
},
{
name: "callable parameter",
param: phpParameter{Name: "callback", PhpType: "callable"},
expected: "callback_callback",
},
{
name: "nullable callable parameter",
param: phpParameter{Name: "callback", PhpType: "callable", IsNullable: true},
expected: "callback_callback",
},
{
name: "unknown type",
param: phpParameter{Name: "unknown", PhpType: phpType("unknown")},
@@ -551,12 +617,22 @@ func TestParameterParser_GenerateSingleParamDeclaration(t *testing.T) {
{
name: "array parameter",
param: phpParameter{Name: "items", PhpType: phpArray, HasDefault: false},
expected: []string{"zval *items = NULL;"},
expected: []string{"zend_array *items = NULL;"},
},
{
name: "nullable array parameter",
param: phpParameter{Name: "items", PhpType: phpArray, HasDefault: false, IsNullable: true},
expected: []string{"zval *items = NULL;"},
expected: []string{"zend_array *items = NULL;"},
},
{
name: "callable parameter",
param: phpParameter{Name: "callback", PhpType: "callable", HasDefault: false},
expected: []string{"zval *callback_callback;"},
},
{
name: "nullable callable parameter",
param: phpParameter{Name: "callback", PhpType: "callable", HasDefault: false, IsNullable: true},
expected: []string{"zval *callback_callback;"},
},
}

View File

@@ -107,8 +107,8 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
},
contains: []string{
"PHP_FUNCTION(process_array)",
"zval *input = NULL;",
"Z_PARAM_ARRAY(input)",
"zend_array *input = NULL;",
"Z_PARAM_ARRAY_HT(input)",
"zend_array *result = process_array(input);",
"RETURN_ARR(result)",
},
@@ -126,10 +126,10 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
},
contains: []string{
"PHP_FUNCTION(filter_array)",
"zval *data = NULL;",
"zend_array *data = NULL;",
"zend_string *key = NULL;",
"zend_long limit = 10;",
"Z_PARAM_ARRAY(data)",
"Z_PARAM_ARRAY_HT(data)",
"Z_PARAM_STR(key)",
"Z_PARAM_LONG(limit)",
"ZEND_PARSE_PARAMETERS_START(2, 3)",
@@ -201,7 +201,7 @@ func TestPHPFunctionGenerator_GenerateParamDeclarations(t *testing.T) {
{Name: "items", PhpType: phpArray},
},
contains: []string{
"zval *items = NULL;",
"zend_array *items = NULL;",
},
},
{
@@ -213,7 +213,7 @@ func TestPHPFunctionGenerator_GenerateParamDeclarations(t *testing.T) {
},
contains: []string{
"zend_string *name = NULL;",
"zval *data = NULL;",
"zend_array *data = NULL;",
"zend_long count = 0;",
},
},

View File

@@ -95,7 +95,9 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) {
zend_bool {{$param.Name}} = {{if $param.HasDefault}}{{if eq $param.DefaultValue "true"}}1{{else}}0{{end}}{{else}}0{{end}};{{if $param.IsNullable}}
zend_bool {{$param.Name}}_is_null = 0;{{end}}
{{- else if eq $param.PhpType "array"}}
zval *{{$param.Name}} = NULL;
zend_array *{{$param.Name}} = NULL;
{{- else if eq $param.PhpType "callable"}}
zval *{{$param.Name}}_callback;
{{- end}}
{{- end}}
@@ -104,7 +106,7 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) {
{{$optionalStarted := false}}{{range .Params}}{{if .HasDefault}}{{if not $optionalStarted -}}
Z_PARAM_OPTIONAL
{{$optionalStarted = true}}{{end}}{{end -}}
{{if .IsNullable}}{{if eq .PhpType "string"}}Z_PARAM_STR_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "int"}}Z_PARAM_LONG_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "float"}}Z_PARAM_DOUBLE_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "bool"}}Z_PARAM_BOOL_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "array"}}Z_PARAM_ARRAY_OR_NULL({{.Name}}){{end}}{{else}}{{if eq .PhpType "string"}}Z_PARAM_STR({{.Name}}){{else if eq .PhpType "int"}}Z_PARAM_LONG({{.Name}}){{else if eq .PhpType "float"}}Z_PARAM_DOUBLE({{.Name}}){{else if eq .PhpType "bool"}}Z_PARAM_BOOL({{.Name}}){{else if eq .PhpType "array"}}Z_PARAM_ARRAY({{.Name}}){{end}}{{end}}
{{if .IsNullable}}{{if eq .PhpType "string"}}Z_PARAM_STR_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "int"}}Z_PARAM_LONG_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "float"}}Z_PARAM_DOUBLE_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "bool"}}Z_PARAM_BOOL_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "array"}}Z_PARAM_ARRAY_HT_OR_NULL({{.Name}}){{else if eq .PhpType "callable"}}Z_PARAM_ZVAL_OR_NULL({{.Name}}_callback){{end}}{{else}}{{if eq .PhpType "string"}}Z_PARAM_STR({{.Name}}){{else if eq .PhpType "int"}}Z_PARAM_LONG({{.Name}}){{else if eq .PhpType "float"}}Z_PARAM_DOUBLE({{.Name}}){{else if eq .PhpType "bool"}}Z_PARAM_BOOL({{.Name}}){{else if eq .PhpType "array"}}Z_PARAM_ARRAY_HT({{.Name}}){{else if eq .PhpType "callable"}}Z_PARAM_ZVAL({{.Name}}_callback){{end}}{{end}}
{{end -}}
ZEND_PARSE_PARAMETERS_END();
{{else}}
@@ -113,22 +115,22 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) {
{{- if ne .ReturnType "void"}}
{{- if eq .ReturnType "string"}}
zend_string* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{.Name}}{{end}}{{end}}{{end}});
zend_string* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}});
if (result) {
RETURN_STR(result);
}
RETURN_EMPTY_STRING();
{{- else if eq .ReturnType "int"}}
zend_long result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(long){{.Name}}{{end}}{{end}}{{end}}{{end}});
zend_long result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}(long){{.Name}}{{end}}{{end}}{{end}}{{end}});
RETURN_LONG(result);
{{- else if eq .ReturnType "float"}}
double result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(double){{.Name}}{{end}}{{end}}{{end}}{{end}});
double result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}(double){{.Name}}{{end}}{{end}}{{end}}{{end}});
RETURN_DOUBLE(result);
{{- else if eq .ReturnType "bool"}}
int result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(int){{.Name}}{{end}}{{end}}{{end}}{{end}});
int result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}(int){{.Name}}{{end}}{{end}}{{end}}{{end}});
RETURN_BOOL(result);
{{- else if eq .ReturnType "array"}}
void* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{.Name}}{{end}}{{end}}{{end}});
void* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}});
if (result != NULL) {
HashTable *ht = (HashTable*)result;
RETURN_ARR(ht);
@@ -137,7 +139,7 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) {
}
{{- end}}
{{- else}}
{{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{if eq .PhpType "string"}}{{.Name}}{{else if eq .PhpType "int"}}(long){{.Name}}{{else if eq .PhpType "float"}}(double){{.Name}}{{else if eq .PhpType "bool"}}(int){{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{end}}{{end}}{{end}});
{{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "string"}}{{.Name}}{{else if eq .PhpType "int"}}(long){{.Name}}{{else if eq .PhpType "float"}}(double){{.Name}}{{else if eq .PhpType "bool"}}(int){{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{end}}{{end}}{{end}});
{{- end}}
}
{{end}}{{end}}

View File

@@ -76,7 +76,7 @@ func create_{{.GoStruct}}_object() C.uintptr_t {
{{- end}}
{{- range .Methods}}
//export {{.Name}}_wrapper
func {{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zval{{else}}, {{.Name}} {{if .IsNullable}}*{{end}}{{phpTypeToGoType .PhpType}}{{end}}{{end}}){{if not (isVoid .ReturnType)}}{{if isStringOrArray .ReturnType}} unsafe.Pointer{{else}} {{phpTypeToGoType .ReturnType}}{{end}}{{end}} {
func {{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zval{{else if eq .PhpType "callable"}}, {{.Name}} *C.zval{{else}}, {{.Name}} {{if .IsNullable}}*{{end}}{{phpTypeToGoType .PhpType}}{{end}}{{end}}){{if not (isVoid .ReturnType)}}{{if isStringOrArray .ReturnType}} unsafe.Pointer{{else}} {{phpTypeToGoType .ReturnType}}{{end}}{{end}} {
obj := getGoObject(handle)
if obj == nil {
{{- if not (isVoid .ReturnType)}}

View File

@@ -11,10 +11,10 @@ import (
)
var (
paramTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed}
paramTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed, phpCallable}
returnTypes = []phpType{phpVoid, phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed, phpNull, phpTrue, phpFalse}
propTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed}
supportedTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpMixed}
supportedTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpMixed, phpCallable}
functionNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
parameterNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
@@ -160,8 +160,10 @@ func (v *Validator) validateGoFunctionSignatureWithOptions(phpFunc phpFunction,
effectiveGoParamCount = goParamCount - 1
}
if len(phpFunc.Params) != effectiveGoParamCount {
return fmt.Errorf("parameter count mismatch: PHP function has %d parameters but Go function has %d", len(phpFunc.Params), effectiveGoParamCount)
expectedGoParams := len(phpFunc.Params)
if expectedGoParams != effectiveGoParamCount {
return fmt.Errorf("parameter count mismatch: PHP function has %d parameters (expecting %d Go parameters) but Go function has %d", len(phpFunc.Params), expectedGoParams, effectiveGoParamCount)
}
if goFunc.Type.Params != nil && len(phpFunc.Params) > 0 {
@@ -203,13 +205,17 @@ func (v *Validator) phpTypeToGoType(t phpType, isNullable bool) string {
baseType = "float64"
case phpBool:
baseType = "bool"
case phpArray, phpMixed:
case phpArray:
baseType = "*C.zend_array"
case phpMixed:
baseType = "*C.zval"
case phpCallable:
baseType = "*C.zval"
default:
baseType = "any"
}
if isNullable && t != phpString && t != phpArray {
if isNullable && t != phpString && t != phpArray && t != phpCallable {
return "*" + baseType
}

View File

@@ -60,6 +60,53 @@ func TestValidateFunction(t *testing.T) {
},
expectError: false,
},
{
name: "valid function with array parameter",
function: phpFunction{
Name: "arrayFunction",
ReturnType: "array",
Params: []phpParameter{
{Name: "items", PhpType: phpArray},
{Name: "filter", PhpType: phpString},
},
},
expectError: false,
},
{
name: "valid function with nullable array parameter",
function: phpFunction{
Name: "nullableArrayFunction",
ReturnType: "string",
Params: []phpParameter{
{Name: "items", PhpType: phpArray, IsNullable: true},
{Name: "name", PhpType: phpString},
},
},
expectError: false,
},
{
name: "valid function with callable parameter",
function: phpFunction{
Name: "callableFunction",
ReturnType: "array",
Params: []phpParameter{
{Name: "data", PhpType: phpArray},
{Name: "callback", PhpType: phpCallable},
},
},
expectError: false,
},
{
name: "valid function with nullable callable parameter",
function: phpFunction{
Name: "nullableCallableFunction",
ReturnType: "string",
Params: []phpParameter{
{Name: "callback", PhpType: phpCallable, IsNullable: true},
},
},
expectError: false,
},
{
name: "empty function name",
function: phpFunction{
@@ -304,6 +351,23 @@ func TestValidateParameter(t *testing.T) {
},
expectError: false,
},
{
name: "valid callable parameter",
param: phpParameter{
Name: "callbackParam",
PhpType: phpCallable,
},
expectError: false,
},
{
name: "valid nullable callable parameter",
param: phpParameter{
Name: "nullableCallbackParam",
PhpType: "callable",
IsNullable: true,
},
expectError: false,
},
{
name: "empty parameter name",
param: phpParameter{
@@ -484,6 +548,28 @@ func TestValidateTypes(t *testing.T) {
},
expectError: false,
},
{
name: "valid callable parameter",
function: phpFunction{
Name: "callableFunction",
ReturnType: "array",
Params: []phpParameter{
{Name: "callbackParam", PhpType: phpCallable},
},
},
expectError: false,
},
{
name: "valid nullable callable parameter",
function: phpFunction{
Name: "nullableCallableFunction",
ReturnType: "string",
Params: []phpParameter{
{Name: "callbackParam", PhpType: phpCallable, IsNullable: true},
},
},
expectError: false,
},
{
name: "invalid object parameter",
function: phpFunction{
@@ -600,7 +686,7 @@ func TestValidateGoFunctionSignature(t *testing.T) {
}`,
},
expectError: true,
errorMsg: "parameter count mismatch: PHP function has 2 parameters but Go function has 1",
errorMsg: "parameter count mismatch: PHP function has 2 parameters (expecting 2 Go parameters) but Go function has 1",
},
{
name: "parameter type mismatch",
@@ -669,7 +755,7 @@ func TestValidateGoFunctionSignature(t *testing.T) {
Params: []phpParameter{
{Name: "items", PhpType: phpArray},
},
GoFunction: `func arrayFunc(items *C.zval) unsafe.Pointer {
GoFunction: `func arrayFunc(items *C.zend_array) unsafe.Pointer {
return nil
}`,
},
@@ -684,7 +770,7 @@ func TestValidateGoFunctionSignature(t *testing.T) {
{Name: "items", PhpType: phpArray, IsNullable: true},
{Name: "name", PhpType: phpString},
},
GoFunction: `func nullableArrayFunc(items *C.zval, name *C.zend_string) unsafe.Pointer {
GoFunction: `func nullableArrayFunc(items *C.zend_array, name *C.zend_string) unsafe.Pointer {
return nil
}`,
},
@@ -700,7 +786,51 @@ func TestValidateGoFunctionSignature(t *testing.T) {
{Name: "filter", PhpType: phpString},
{Name: "limit", PhpType: phpInt},
},
GoFunction: `func mixedFunc(data *C.zval, filter *C.zend_string, limit int64) unsafe.Pointer {
GoFunction: `func mixedFunc(data *C.zend_array, filter *C.zend_string, limit int64) unsafe.Pointer {
return nil
}`,
},
expectError: false,
},
{
name: "valid callable parameter",
phpFunc: phpFunction{
Name: "callableFunc",
ReturnType: "array",
Params: []phpParameter{
{Name: "callback", PhpType: phpCallable},
},
GoFunction: `func callableFunc(callback *C.zval) unsafe.Pointer {
return nil
}`,
},
expectError: false,
},
{
name: "valid nullable callable parameter",
phpFunc: phpFunction{
Name: "nullableCallableFunc",
ReturnType: "string",
Params: []phpParameter{
{Name: "callback", PhpType: phpCallable, IsNullable: true},
},
GoFunction: `func nullableCallableFunc(callback *C.zval) unsafe.Pointer {
return nil
}`,
},
expectError: false,
},
{
name: "mixed callable and other parameters",
phpFunc: phpFunction{
Name: "mixedCallableFunc",
ReturnType: "array",
Params: []phpParameter{
{Name: "data", PhpType: phpArray},
{Name: "callback", PhpType: phpCallable},
{Name: "options", PhpType: "int"},
},
GoFunction: `func mixedCallableFunc(data *C.zend_array, callback *C.zval, options int64) unsafe.Pointer {
return nil
}`,
},
@@ -737,8 +867,10 @@ func TestPhpTypeToGoType(t *testing.T) {
{"float", true, "*float64"},
{"bool", false, "bool"},
{"bool", true, "*bool"},
{"array", false, "*C.zval"},
{"array", true, "*C.zval"},
{"array", false, "*C.zend_array"},
{"array", true, "*C.zend_array"},
{"callable", false, "*C.zval"},
{"callable", true, "*C.zval"},
{"unknown", false, "any"},
}

View File

@@ -6,6 +6,23 @@ import (
"os"
"path/filepath"
)
var (
wd string
wderr error
)
func init() {
wd, wderr = os.Getwd()
if wderr != nil {
return
}
canonicalWD, err := filepath.EvalSymlinks(wd)
if err != nil {
wd = canonicalWD
}
}
// FastAbs is an optimized version of filepath.Abs for Unix systems,
// since we don't expect the working directory to ever change once
@@ -22,4 +39,3 @@ func FastAbs(path string) (string, error) {
return filepath.Join(wd, path), nil
}
var wd, wderr = os.Getwd()

View File

@@ -96,15 +96,20 @@ func (ts *ThreadState) notifySubscribers(nextState State) {
if len(ts.subscribers) == 0 {
return
}
newSubscribers := []stateSubscriber{}
var newSubscribers []stateSubscriber
// notify subscribers to the state change
for _, sub := range ts.subscribers {
if !slices.Contains(sub.states, nextState) {
newSubscribers = append(newSubscribers, sub)
continue
}
close(sub.ch)
}
ts.subscribers = newSubscribers
}
@@ -131,22 +136,25 @@ func (ts *ThreadState) RequestSafeStateChange(nextState State) bool {
// disallow state changes if shutting down or done
case ShuttingDown, Done, Reserved:
ts.mu.Unlock()
return false
// ready and inactive are safe states to transition from
case Ready, Inactive:
ts.currentState = nextState
ts.notifySubscribers(nextState)
ts.mu.Unlock()
return true
}
ts.mu.Unlock()
// wait for the state to change to a safe state
ts.WaitFor(Ready, Inactive, ShuttingDown)
return ts.RequestSafeStateChange(nextState)
}
// markAsWaiting hints that the thread reached a stable state and is waiting for requests or shutdown
// MarkAsWaiting hints that the thread reached a stable state and is waiting for requests or shutdown
func (ts *ThreadState) MarkAsWaiting(isWaiting bool) {
ts.mu.Lock()
if isWaiting {
@@ -158,15 +166,16 @@ func (ts *ThreadState) MarkAsWaiting(isWaiting bool) {
ts.mu.Unlock()
}
// isWaitingState returns true if a thread is waiting for a request or shutdown
// IsInWaitingState returns true if a thread is waiting for a request or shutdown
func (ts *ThreadState) IsInWaitingState() bool {
ts.mu.RLock()
isWaiting := ts.isWaiting
ts.mu.RUnlock()
return isWaiting
}
// waitTime returns the time since the thread is waiting in a stable state in ms
// WaitTime returns the time since the thread is waiting in a stable state in ms
func (ts *ThreadState) WaitTime() int64 {
ts.mu.RLock()
waitTime := int64(0)
@@ -174,6 +183,7 @@ func (ts *ThreadState) WaitTime() int64 {
waitTime = time.Now().UnixMilli() - ts.waitingSince.UnixMilli()
}
ts.mu.RUnlock()
return waitTime
}

View File

@@ -1,6 +0,0 @@
//go:build !nowatcher
package watcher
// #cgo LDFLAGS: -lwatcher-c -lstdc++
import "C"

224
internal/watcher/pattern.go Normal file
View File

@@ -0,0 +1,224 @@
//go:build !nowatcher
package watcher
import (
"log/slog"
"path/filepath"
"strings"
"github.com/dunglas/frankenphp/internal/fastabs"
"github.com/e-dant/watcher/watcher-go"
)
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)
if err != nil {
return err
}
p.value = absPattern
// then we split the pattern to determine where the directory ends and the pattern starts
splitPattern := strings.Split(absPattern, string(filepath.Separator))
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, string(filepath.Separator))
}
// remove the trailing separator and add leading separator
p.value = string(filepath.Separator) + strings.Trim(p.value, string(filepath.Separator))
// try to canonicalize the path
canonicalPattern, err := filepath.EvalSymlinks(p.value)
if err == nil {
p.value = canonicalPattern
}
return nil
}
func (p *pattern) allowReload(event *watcher.Event) bool {
if !isValidEventType(event.EffectType) || !isValidPathType(event) {
return false
}
// 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
}
if p.allowReload(event) {
p.events <- eventHolder{p.patternGroup, event}
}
}
func (p *pattern) stop() {
p.watcher.Close()
}
func isValidEventType(effectType watcher.EffectType) bool {
return effectType <= watcher.EffectTypeDestroy
}
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))
}
return event.PathType <= watcher.PathTypeHardLink
}
func (p *pattern) isValidPattern(fileName string) bool {
if fileName == "" {
return false
}
// first we remove the dir from the file name
if !strings.HasPrefix(fileName, p.value) {
return false
}
// remove the directory path and separator from the filename
fileNameWithoutDir := strings.TrimPrefix(strings.TrimPrefix(fileName, p.value), string(filepath.Separator))
// 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)
}
return p.matchPatterns(fileNameWithoutDir)
}
func (p *pattern) matchPatterns(fileName string) bool {
partsToMatch := strings.Split(fileName, string(filepath.Separator))
cursor := 0
// if there are multiple parsedValues due to '**' we need to match them individually
for i, pattern := range p.parsedValues {
patternSize := strings.Count(pattern, string(filepath.Separator)) + 1
// 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
if cursor < 0 {
return false
}
}
// 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
}
cursor = j
subPattern := strings.Join(partsToMatch[j:j+patternSize], string(filepath.Separator))
if matchCurlyBracePattern(pattern, subPattern) {
cursor = j + patternSize - 1
break
}
if cursor > len(partsToMatch)-patternSize-1 {
return false
}
}
}
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
}
}
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.Split(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
}
return patternMatches
}

View File

@@ -0,0 +1,357 @@
//go:build !nowatcher
package watcher
import (
"path/filepath"
"testing"
"github.com/e-dant/watcher/watcher-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDisallowOnEventTypeBiggerThan3(t *testing.T) {
w := pattern{value: "/some/path"}
require.NoError(t, w.parse())
assert.False(t, w.allowReload(&watcher.Event{PathName: "/some/path/watch-me.php", EffectType: watcher.EffectTypeOwner}))
}
func TestDisallowOnPathTypeBiggerThan2(t *testing.T) {
w := pattern{value: "/some/path"}
require.NoError(t, w.parse())
assert.False(t, w.allowReload(&watcher.Event{PathName: "/some/path/watch-me.php", PathType: watcher.PathTypeSymLink}))
}
func TestWatchesCorrectDir(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/path", "/path"},
{"/path/", "/path"},
{"/path/**/*.php", "/path"},
{"/path/*.php", "/path"},
{"/path/*/*.php", "/path"},
{"/path/?path/*.php", "/path"},
{"/path/{dir1,dir2}/**/*.php", "/path"},
{".", relativeDir(t, "")},
{"./", relativeDir(t, "")},
{"./**", relativeDir(t, "")},
{"..", relativeDir(t, "/..")},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
hasDir(t, d.pattern, d.dir)
})
}
}
func TestValidRecursiveDirectories(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/path", "/path/file.php"},
{"/path", "/path/subpath/file.php"},
{"/path/", "/path/subpath/file.php"},
{"/path**", "/path/subpath/file.php"},
{"/path/**", "/path/subpath/file.php"},
{"/path/**/", "/path/subpath/file.php"},
{".", relativeDir(t, "file.php")},
{".", relativeDir(t, "subpath/file.php")},
{"./**", relativeDir(t, "subpath/file.php")},
{"..", relativeDir(t, "subpath/file.php")},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldMatch(t, d.pattern, d.dir)
})
}
}
func TestInvalidRecursiveDirectories(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/path", "/other/file.php"},
{"/path/**", "/other/file.php"},
{".", "/other/file.php"},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldNotMatch(t, d.pattern, d.dir)
})
}
}
func TestValidNonRecursiveFilePatterns(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/*.php", "/file.php"},
{"/path/*.php", "/path/file.php"},
{"/path/?ile.php", "/path/file.php"},
{"/path/file.php", "/path/file.php"},
{"*.php", relativeDir(t, "file.php")},
{"./*.php", relativeDir(t, "file.php")},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldMatch(t, d.pattern, d.dir)
})
}
}
func TestInValidNonRecursiveFilePatterns(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/path/*.txt", "/path/file.php"},
{"/path/*.php", "/path/subpath/file.php"},
{"/*.php", "/path/file.php"},
{"*.txt", relativeDir(t, "file.php")},
{"*.php", relativeDir(t, "subpath/file.php")},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldNotMatch(t, d.pattern, d.dir)
})
}
}
func TestValidRecursiveFilePatterns(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/path/**/*.php", "/path/file.php"},
{"/path/**/*.php", "/path/subpath/file.php"},
{"/path/**/?ile.php", "/path/subpath/file.php"},
{"/path/**/file.php", "/path/subpath/file.php"},
{"**/*.php", relativeDir(t, "file.php")},
{"**/*.php", relativeDir(t, "subpath/file.php")},
{"./**/*.php", relativeDir(t, "subpath/file.php")},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldMatch(t, d.pattern, d.dir)
})
}
}
func TestInvalidRecursiveFilePatterns(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/path/**/*.txt", "/path/file.php"},
{"/path/**/*.txt", "/other/file.php"},
{"/path/**/*.txt", "/path/subpath/file.php"},
{"/path/**/?ilm.php", "/path/subpath/file.php"},
{"**/*.php", "/other/file.php"},
{".**/*.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"},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldNotMatch(t, d.pattern, d.dir)
})
}
}
func TestValidDirectoryPatterns(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/path/*/*.php", "/path/subpath/file.php"},
{"/path/*/*/*.php", "/path/subpath/subpath/file.php"},
{"/path/?/*.php", "/path/1/file.php"},
{"/path/**/vendor/*.php", "/path/vendor/file.php"},
{"/path/**/vendor/*.php", "/path/subpath/vendor/file.php"},
{"/path/**/vendor/**/*.php", "/path/vendor/file.php"},
{"/path/**/vendor/**/*.php", "/path/subpath/subpath/vendor/subpath/subpath/file.php"},
{"/path/**/vendor/*/*.php", "/path/subpath/subpath/vendor/subpath/file.php"},
{"/path*/path*/*", "/path1/path2/file.php"},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldMatch(t, d.pattern, d.dir)
})
}
}
func TestInvalidDirectoryPatterns(t *testing.T) {
t.Parallel()
data := []struct {
pattern string
dir string
}{
{"/path/subpath/*.php", "/path/other/file.php"},
{"/path/*/*.php", "/path/subpath/subpath/file.php"},
{"/path/?/*.php", "/path/subpath/file.php"},
{"/path/*/*/*.php", "/path/subpath/file.php"},
{"/path/*/*/*.php", "/path/subpath/subpath/subpath/file.php"},
{"/path/**/vendor/*.php", "/path/subpath/vendor/subpath/file.php"},
{"/path/**/vendor/*.php", "/path/subpath/file.php"},
{"/path/**/vendor/**/*.php", "/path/subpath/file.php"},
{"/path/**/vendor/**/*.txt", "/path/subpath/vendor/subpath/file.php"},
{"/path/**/vendor/**/*.php", "/path/subpath/subpath/subpath/file.php"},
{"/path/**/vendor/*/*.php", "/path/subpath/vendor/subpath/subpath/file.php"},
{"/path*/path*", "/path1/path1/file.php"},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldNotMatch(t, d.pattern, d.dir)
})
}
}
func TestValidCurlyBracePatterns(t *testing.T) {
data := []struct {
pattern string
dir string
}{
{"/path/*.{php}", "/path/file.php"},
{"/path/*.{php,twig}", "/path/file.php"},
{"/path/*.{php,twig}", "/path/file.twig"},
{"/path/**/{file.php,file.twig}", "/path/subpath/file.twig"},
{"/path/{dir1,dir2}/file.php", "/path/dir1/file.php"},
{"/path/{dir1,dir2}/file.php", "/path/dir2/file.php"},
{"/app/{app,config,resources}/**/*.php", "/app/app/subpath/file.php"},
{"/app/{app,config,resources}/**/*.php", "/app/config/subpath/file.php"},
{"/path/{dir1,dir2}/{a,b}{a,b}.php", "/path/dir1/ab.php"},
{"/path/{dir1,dir2}/{a,b}{a,b}.php", "/path/dir2/aa.php"},
{"/path/{dir1,dir2}/{a,b}{a,b}.php", "/path/dir2/bb.php"},
{"/path/{dir1/test.php,dir2/test.php}", "/path/dir1/test.php"},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldMatch(t, d.pattern, d.dir)
})
}
}
func TestInvalidCurlyBracePatterns(t *testing.T) {
data := []struct {
pattern string
dir string
}{
{"/path/*.{php}", "/path/file.txt"},
{"/path/*.{php,twig}", "/path/file.txt"},
{"/path/{file.php,file.twig}", "/path/file.txt"},
{"/path/{dir1,dir2}/file.php", "/path/dir3/file.php"},
{"/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"},
}
for _, d := range data {
t.Run(d.pattern, func(t *testing.T) {
t.Parallel()
shouldNotMatch(t, d.pattern, d.dir)
})
}
}
func TestAnAssociatedEventTriggersTheWatcher(t *testing.T) {
w := pattern{value: "/**/*.php"}
require.NoError(t, w.parse())
w.events = make(chan eventHolder)
e := &watcher.Event{PathName: "/path/temporary_file", AssociatedPathName: "/path/file.php"}
go w.handle(e)
assert.Equal(t, e, (<-w.events).event)
}
func relativeDir(t *testing.T, relativePath string) string {
dir, err := filepath.Abs("./" + relativePath)
assert.NoError(t, err)
return dir
}
func hasDir(t *testing.T, p string, dir string) {
t.Helper()
w := pattern{value: p}
require.NoError(t, w.parse())
assert.Equal(t, dir, w.value)
}
func shouldMatch(t *testing.T, p string, fileName string) {
t.Helper()
w := pattern{value: p}
require.NoError(t, w.parse())
assert.True(t, w.allowReload(&watcher.Event{PathName: fileName}))
}
func shouldNotMatch(t *testing.T, p string, fileName string) {
t.Helper()
w := pattern{value: p}
require.NoError(t, w.parse())
assert.False(t, w.allowReload(&watcher.Event{PathName: fileName}))
}

View File

@@ -1,178 +0,0 @@
//go:build !nowatcher
package watcher
import (
"log/slog"
"path/filepath"
"strings"
"github.com/dunglas/frankenphp/internal/fastabs"
)
type watchPattern struct {
dir string
patterns []string
trigger chan string
failureCount int
}
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)
func parseFilePattern(filePattern string) (*watchPattern, error) {
w := &watchPattern{}
// first we clean the pattern
absPattern, err := fastabs.FastAbs(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, string(filepath.Separator))
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, string(filepath.Separator))
}
// finally, we remove the trailing separator and add leading separator
w.dir = string(filepath.Separator) + strings.Trim(w.dir, string(filepath.Separator))
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.Enabled(ctx, slog.LevelDebug) {
logger.LogAttrs(ctx, slog.LevelDebug, "special edant/watcher event", slog.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
}
// remove the dir and separator from the filename
fileNameWithoutDir := strings.TrimPrefix(strings.TrimPrefix(fileName, dir), string(filepath.Separator))
// 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, string(filepath.Separator))
cursor := 0
// if there are multiple patterns due to '**' we need to match them individually
for i, pattern := range patterns {
patternSize := strings.Count(pattern, string(filepath.Separator)) + 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], string(filepath.Separator))
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.SplitSeq(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 {
if logger.Enabled(ctx, slog.LevelError) {
logger.LogAttrs(ctx, slog.LevelError, "failed to match filename", slog.String("file", fileName), slog.Any("error", err))
}
return false
}
return patternMatches
}

View File

@@ -1,187 +0,0 @@
//go:build !nowatcher
package watcher
import (
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
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, "/path/{dir1,dir2}/**/*.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/{dir1,dir2}/file.php", "/path/dir1/file.php")
shouldMatch(t, "/path/{dir1,dir2}/file.php", "/path/dir2/file.php")
shouldMatch(t, "/app/{app,config,resources}/**/*.php", "/app/app/subpath/file.php")
shouldMatch(t, "/app/{app,config,resources}/**/*.php", "/app/config/subpath/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/{dir1,dir2}/file.php", "/path/dir3/file.php")
shouldNotMatch(t, "/path/{dir1,dir2}/**/*.php", "/path/dir1/subpath/file.txt")
}
func TestAnAssociatedEventTriggersTheWatcher(t *testing.T) {
watchPattern, err := parseFilePattern("/**/*.php")
assert.NoError(t, err)
watchPattern.trigger = make(chan string)
go handleWatcherEvent(watchPattern, "/path/temorary_file", "/path/file.php", 0, 0)
var path string
select {
case path = <-watchPattern.trigger:
assert.Equal(t, "/path/file.php", path, "should be associated file path")
case <-time.After(2 * time.Second):
assert.Fail(t, "associated watchPattern did not trigger after 2s")
}
}
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))
}

View File

@@ -1,17 +0,0 @@
//go:build nowatcher
package watcher
import (
"context"
"log/slog"
)
func InitWatcher(ct context.Context, filePatterns []string, callback func(), logger *slog.Logger) error {
logger.Error("watcher support is not enabled")
return nil
}
func DrainWatcher() {
}

View File

@@ -1,26 +0,0 @@
// clang-format off
//go:build !nowatcher
// clang-format on
#include "_cgo_export.h"
#include "wtr/watcher-c.h"
void handle_event(struct wtr_watcher_event event, void *data) {
go_handle_file_watcher_event(
(char *)event.path_name, (char *)event.associated_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;
}

View File

@@ -2,55 +2,60 @@
package watcher
// #include <stdint.h>
// #include <stdlib.h>
// #include "watcher.h"
import "C"
import (
"context"
"errors"
"log/slog"
"runtime/cgo"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
"github.com/e-dant/watcher/watcher-go"
)
type watcher struct {
sessions []C.uintptr_t
callback func()
trigger chan string
stop chan struct{}
}
// duration to wait before triggering a reload after a file change
const debounceDuration = 150 * time.Millisecond
// times to retry watching if the watcher was closed prematurely
const maxFailureCount = 5
const failureResetDuration = 5 * time.Second
var failureMu = sync.Mutex{}
var watcherIsActive = atomic.Bool{}
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
)
var (
ErrAlreadyStarted = errors.New("the watcher is already running")
ErrUnableToStartWatching = errors.New("unable to start the watcher")
ErrAlreadyStarted = errors.New("watcher is already running")
failureMu sync.Mutex
watcherIsActive atomic.Bool
// the currently active file watcher
activeWatcher *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
ctx context.Context
// we are passing the logger from the main package to the watcher
logger *slog.Logger
globalCtx context.Context
// we are passing the globalLogger from the main package to the watcher
globalLogger *slog.Logger
)
func InitWatcher(ct context.Context, filePatterns []string, callback func(), slogger *slog.Logger) error {
if len(filePatterns) == 0 {
type PatternGroup struct {
Patterns []string
Callback func([]*watcher.Event)
}
type eventHolder struct {
patternGroup *PatternGroup
event *watcher.Event
}
type globalWatcher struct {
groups []*PatternGroup
watchers []*pattern
events chan eventHolder
stop chan struct{}
}
func InitWatcher(ct context.Context, slogger *slog.Logger, groups []*PatternGroup) error {
if len(groups) == 0 {
return nil
}
@@ -59,11 +64,22 @@ func InitWatcher(ct context.Context, filePatterns []string, callback func(), slo
}
watcherIsActive.Store(true)
ctx = ct
logger = slogger
activeWatcher = &watcher{callback: callback}
globalCtx = ct
globalLogger = slogger
if err := activeWatcher.startWatching(ctx, filePatterns); err != nil {
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 {
return err
}
@@ -77,8 +93,8 @@ func DrainWatcher() {
watcherIsActive.Store(false)
if logger.Enabled(ctx, slog.LevelDebug) {
logger.LogAttrs(ctx, slog.LevelDebug, "stopping watcher")
if globalLogger.Enabled(globalCtx, slog.LevelDebug) {
globalLogger.LogAttrs(globalCtx, slog.LevelDebug, "stopping watcher")
}
activeWatcher.stopWatching()
@@ -87,142 +103,119 @@ func DrainWatcher() {
}
// TODO: how to test this?
func retryWatching(watchPattern *watchPattern) {
func (p *pattern) retryWatching() {
failureMu.Lock()
defer failureMu.Unlock()
if watchPattern.failureCount >= maxFailureCount {
if logger.Enabled(ctx, slog.LevelWarn) {
logger.LogAttrs(ctx, slog.LevelWarn, "giving up watching", slog.String("dir", watchPattern.dir))
if p.failureCount >= maxFailureCount {
if globalLogger.Enabled(globalCtx, slog.LevelWarn) {
globalLogger.LogAttrs(globalCtx, slog.LevelWarn, "giving up watching", slog.String("pattern", p.value))
}
return
}
if logger.Enabled(ctx, slog.LevelInfo) {
logger.LogAttrs(ctx, slog.LevelInfo, "watcher was closed prematurely, retrying...", slog.String("dir", watchPattern.dir))
if globalLogger.Enabled(globalCtx, slog.LevelInfo) {
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "watcher was closed prematurely, retrying...", slog.String("pattern", p.value))
}
watchPattern.failureCount++
session, err := startSession(watchPattern)
if err != nil {
activeWatcher.sessions = append(activeWatcher.sessions, session)
}
p.failureCount++
p.startSession()
// reset the failure-count if the watcher hasn't reached max failures after 5 seconds
go func() {
time.Sleep(failureResetDuration * time.Second)
time.Sleep(failureResetDuration)
failureMu.Lock()
if watchPattern.failureCount < maxFailureCount {
watchPattern.failureCount = 0
if p.failureCount < maxFailureCount {
p.failureCount = 0
}
failureMu.Unlock()
}()
}
func (w *watcher) startWatching(ctx context.Context, filePatterns []string) error {
w.trigger = make(chan string)
w.stop = make(chan struct{})
w.sessions = make([]C.uintptr_t, len(filePatterns))
watchPatterns, err := parseFilePatterns(filePatterns)
if err != nil {
func (g *globalWatcher) startWatching() error {
g.events = make(chan eventHolder)
g.stop = make(chan struct{})
if err := g.parseFilePatterns(); 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
for _, w := range g.watchers {
w.events = g.events
w.startSession()
}
go listenForFileEvents(w.trigger, w.stop)
go g.listenForFileEvents()
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 {
if logger.Enabled(ctx, slog.LevelDebug) {
logger.LogAttrs(ctx, slog.LevelDebug, "watching", slog.String("dir", w.dir), slog.Any("patterns", w.patterns))
func (g *globalWatcher) parseFilePatterns() error {
for _, w := range g.watchers {
if err := w.parse(); err != nil {
return err
}
return watchSession, nil
}
if logger.Enabled(ctx, slog.LevelError) {
logger.LogAttrs(ctx, slog.LevelError, "couldn't start watching", slog.String("dir", w.dir))
}
return watchSession, ErrUnableToStartWatching
return nil
}
func stopSession(session C.uintptr_t) {
success := C.stop_watcher(session)
if success == 0 && logger.Enabled(ctx, slog.LevelWarn) {
logger.LogAttrs(ctx, slog.LevelWarn, "couldn't close the watcher")
func (g *globalWatcher) stopWatching() {
close(g.stop)
for _, w := range g.watchers {
w.stop()
}
}
//export go_handle_file_watcher_event
func go_handle_file_watcher_event(path *C.char, associatedPath *C.char, eventType C.int, pathType C.int, handle C.uintptr_t) {
watchPattern := cgo.Handle(handle).Value().(*watchPattern)
handleWatcherEvent(watchPattern, C.GoString(path), C.GoString(associatedPath), int(eventType), int(pathType))
}
func handleWatcherEvent(watchPattern *watchPattern, path string, associatedPath string, eventType int, pathType int) {
// If the watcher prematurely sends the die@ event, retry watching
if pathType == 4 && strings.HasPrefix(path, "e/self/die@") && watcherIsActive.Load() {
retryWatching(watchPattern)
return
}
if watchPattern.allowReload(path, eventType, pathType) {
watchPattern.trigger <- path
return
}
// some editors create temporary files and never actually modify the original file
// so we need to also check the associated path of an event
// see https://github.com/php/frankenphp/issues/1375
if associatedPath != "" && watchPattern.allowReload(associatedPath, eventType, pathType) {
watchPattern.trigger <- associatedPath
}
}
func listenForFileEvents(triggerWatcher chan string, stopWatcher chan struct{}) {
func (g *globalWatcher) listenForFileEvents() {
timer := time.NewTimer(debounceDuration)
timer.Stop()
lastChangedFile := ""
eventsPerGroup := make(map[*PatternGroup][]*watcher.Event, len(g.groups))
defer timer.Stop()
for {
select {
case <-stopWatcher:
case lastChangedFile = <-triggerWatcher:
case <-g.stop:
return
case eh := <-g.events:
timer.Reset(debounceDuration)
eventsPerGroup[eh.patternGroup] = append(eventsPerGroup[eh.patternGroup], eh.event)
case <-timer.C:
timer.Stop()
if logger.Enabled(ctx, slog.LevelInfo) {
logger.LogAttrs(ctx, slog.LevelInfo, "filesystem change detected", slog.String("file", lastChangedFile))
if globalLogger.Enabled(globalCtx, slog.LevelInfo) {
var events []*watcher.Event
for _, eventList := range eventsPerGroup {
events = append(events, eventList...)
}
globalLogger.LogAttrs(globalCtx, slog.LevelInfo, "filesystem changes detected", slog.Any("events", events))
}
scheduleReload()
g.scheduleReload(eventsPerGroup)
eventsPerGroup = make(map[*PatternGroup][]*watcher.Event, len(g.groups))
}
}
}
func scheduleReload() {
func (g *globalWatcher) scheduleReload(eventsPerGroup map[*PatternGroup][]*watcher.Event) {
reloadWaitGroup.Add(1)
activeWatcher.callback()
// Call callbacks in order
for _, g := range g.groups {
if len(g.Patterns) == 0 {
g.Callback(nil)
}
if e, ok := eventsPerGroup[g]; ok {
g.Callback(e)
}
}
reloadWaitGroup.Done()
}

View File

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

37
log_test.go Normal file
View File

@@ -0,0 +1,37 @@
package frankenphp_test
import (
"bytes"
"fmt"
"log/slog"
"sync"
"testing"
)
func newTestLogger(t *testing.T) (*slog.Logger, fmt.Stringer) {
t.Helper()
var buf syncBuffer
return slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), &buf
}
// SyncBuffer is a thread-safe buffer for capturing logs in tests.
type syncBuffer struct {
b bytes.Buffer
mu sync.RWMutex
}
func (s *syncBuffer) Write(p []byte) (n int, err error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.b.String()
}

View File

@@ -5,14 +5,14 @@ package frankenphp
// #include <stdint.h>
// #include <php.h>
import "C"
import (
"unsafe"
)
type mercureContext struct {
}
//export go_mercure_publish
func go_mercure_publish(_ C.uintptr_t, _ *C.struct__zval_struct, _ unsafe.Pointer, _ bool, _, _ unsafe.Pointer, _ uint64) (*C.zend_string, C.short) {
func go_mercure_publish(threadIndex C.uintptr_t, topics *C.struct__zval_struct, data *C.zend_string, private bool, id, typ *C.zend_string, retry uint64) (generatedID *C.zend_string, error C.short) {
return nil, 3
}
func (w *worker) configureMercure(_ *workerOpt) {
}

View File

@@ -38,6 +38,7 @@ func go_mercure_publish(threadIndex C.uintptr_t, topics *C.struct__zval_struct,
Type: GoString(unsafe.Pointer(typ)),
},
Private: private,
Debug: fc.logger.Enabled(ctx, slog.LevelDebug),
}
zvalType := C.zval_get_type(topics)
@@ -45,7 +46,7 @@ func go_mercure_publish(threadIndex C.uintptr_t, topics *C.struct__zval_struct,
case C.IS_STRING:
u.Topics = []string{GoString(unsafe.Pointer(*(**C.zend_string)(unsafe.Pointer(&topics.value[0]))))}
case C.IS_ARRAY:
ts, err := GoPackedArray[string](unsafe.Pointer(topics))
ts, err := GoPackedArray[string](unsafe.Pointer(*(**C.zend_array)(unsafe.Pointer(&topics.value[0]))))
if err != nil {
if fc.logger.Enabled(ctx, slog.LevelError) {
fc.logger.LogAttrs(ctx, slog.LevelError, "invalid topics type", slog.Any("error", err))
@@ -71,6 +72,14 @@ func go_mercure_publish(threadIndex C.uintptr_t, topics *C.struct__zval_struct,
return (*C.zend_string)(PHPString(u.ID, false)), 0
}
func (w *worker) configureMercure(o *workerOpt) {
if o.mercureHub == nil {
return
}
w.mercureHub = o.mercureHub
}
// WithMercureHub sets the mercure.Hub to use to publish updates
func WithMercureHub(hub *mercure.Hub) RequestOption {
return func(o *frankenPHPContext) error {
@@ -79,3 +88,14 @@ func WithMercureHub(hub *mercure.Hub) RequestOption {
return nil
}
}
// WithWorkerMercureHub sets the mercure.Hub in the worker script and used to dispatch hot reloading-related mercure.Update.
func WithWorkerMercureHub(hub *mercure.Hub) WorkerOption {
return func(w *workerOpt) error {
w.mercureHub = hub
w.requestOptions = append(w.requestOptions, WithMercureHub(hub))
return nil
}
}

View File

@@ -20,6 +20,8 @@ type WorkerOption func(*workerOpt) error
//
// If you change this, also update the Caddy module and the documentation.
type opt struct {
hotReloadOpt
ctx context.Context
numThreads int
maxThreads int
@@ -31,6 +33,8 @@ type opt struct {
}
type workerOpt struct {
mercureContext
name string
fileName string
num int

View File

@@ -2,7 +2,6 @@ package frankenphp
import (
"io"
"log/slog"
"math/rand/v2"
"net/http/httptest"
"path/filepath"
@@ -93,10 +92,15 @@ func TestTransitionAThreadBetween2DifferentWorkers(t *testing.T) {
// try all possible handler transitions
// takes around 200ms and is supposed to force race conditions
func TestTransitionThreadsWhileDoingRequests(t *testing.T) {
t.Cleanup(Shutdown)
var (
isDone atomic.Bool
wg sync.WaitGroup
)
numThreads := 10
numRequestsPerThread := 100
isDone := atomic.Bool{}
wg := sync.WaitGroup{}
worker1Path := testDataPath + "/transition-worker-1.php"
worker1Name := "worker-1"
worker2Path := testDataPath + "/transition-worker-2.php"
@@ -114,7 +118,6 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) {
WithWorkerWatchMode([]string{}),
WithWorkerMaxFailures(0),
),
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
))
// try all possible permutations of transition, transition every ms
@@ -155,7 +158,6 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) {
// we are finished as soon as all 1000 requests are done
wg.Wait()
isDone.Store(true)
Shutdown()
}
func TestFinishBootingAWorkerScript(t *testing.T) {

View File

@@ -1,8 +1,6 @@
package frankenphp
import (
"io"
"log/slog"
"testing"
"time"
@@ -11,10 +9,11 @@ import (
)
func TestScaleARegularThreadUpAndDown(t *testing.T) {
t.Cleanup(Shutdown)
assert.NoError(t, Init(
WithNumThreads(1),
WithMaxThreads(2),
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
))
autoScaledThread := phpThreads[1]
@@ -25,14 +24,14 @@ func TestScaleARegularThreadUpAndDown(t *testing.T) {
assert.IsType(t, &regularThread{}, autoScaledThread.handler)
// on down-scale, the thread will be marked as inactive
setLongWaitTime(autoScaledThread)
setLongWaitTime(t, autoScaledThread)
deactivateThreads()
assert.IsType(t, &inactiveThread{}, autoScaledThread.handler)
Shutdown()
}
func TestScaleAWorkerThreadUpAndDown(t *testing.T) {
t.Cleanup(Shutdown)
workerName := "worker1"
workerPath := testDataPath + "/transition-worker-1.php"
assert.NoError(t, Init(
@@ -43,7 +42,6 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) {
WithWorkerWatchMode([]string{}),
WithWorkerMaxFailures(0),
),
WithLogger(slog.New(slog.NewTextHandler(io.Discard, nil))),
))
autoScaledThread := phpThreads[2]
@@ -53,13 +51,13 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) {
assert.Equal(t, state.Ready, autoScaledThread.state.Get())
// on down-scale, the thread will be marked as inactive
setLongWaitTime(autoScaledThread)
setLongWaitTime(t, autoScaledThread)
deactivateThreads()
assert.IsType(t, &inactiveThread{}, autoScaledThread.handler)
Shutdown()
}
func setLongWaitTime(thread *phpThread) {
func setLongWaitTime(t *testing.T, thread *phpThread) {
t.Helper()
thread.state.SetWaitTime(time.Now().Add(-time.Hour))
}

View File

@@ -12,9 +12,11 @@ ENV PHP_VERSION=${PHP_VERSION}
# args passed to static-php-cli
ARG PHP_EXTENSIONS=''
ARG PHP_EXTENSION_LIBS=''
ARG SPC_OPT_BUILD_ARGS
# args passed to xcaddy
ARG XCADDY_ARGS=''
ARG XCADDY_ARGS='--with github.com/dunglas/caddy-cbrotli --with github.com/dunglas/mercure/caddy --with github.com/dunglas/vulcain/caddy'
ENV SPC_CMD_VAR_FRANKENPHP_XCADDY_MODULES="${XCADDY_ARGS}"
ARG CLEAN=''
ARG EMBED=''
ARG DEBUG_SYMBOLS=''
@@ -121,7 +123,8 @@ ENV SPC_DEFAULT_C_FLAGS='-fPIE -fPIC -O3'
ENV SPC_LIBC='glibc'
ENV SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS_PROGRAM='-Wl,-O3 -pie'
ENV SPC_CMD_VAR_PHP_MAKE_EXTRA_LIBS='-ldl -lpthread -lm -lresolv -lutil -lrt'
ENV SPC_OPT_BUILD_ARGS='--with-config-file-path=/etc/frankenphp --with-config-file-scan-dir=/etc/frankenphp/php.d'
# Keep default config paths and append any externally provided SPC_OPT_BUILD_ARGS (e.g., from CI)
ENV SPC_OPT_BUILD_ARGS="--with-config-file-path=/etc/frankenphp --with-config-file-scan-dir=/etc/frankenphp/php.d ${SPC_OPT_BUILD_ARGS}"
ENV SPC_REL_TYPE='binary'
ENV EXTENSION_DIR='/usr/lib/frankenphp/modules'

View File

@@ -12,9 +12,14 @@ ENV FRANKENPHP_VERSION=${FRANKENPHP_VERSION}
ARG PHP_VERSION=''
ENV PHP_VERSION=${PHP_VERSION}
# args passed to static-php-cli
ARG PHP_EXTENSIONS=''
ARG PHP_EXTENSION_LIBS=''
ARG XCADDY_ARGS=''
ARG SPC_OPT_BUILD_ARGS
# args passed to xcaddy
ARG XCADDY_ARGS='--with github.com/dunglas/caddy-cbrotli --with github.com/dunglas/mercure/caddy --with github.com/dunglas/vulcain/caddy'
ENV SPC_CMD_VAR_FRANKENPHP_XCADDY_MODULES="${XCADDY_ARGS}"
ARG CLEAN=''
ARG EMBED=''
ARG DEBUG_SYMBOLS=''
@@ -99,7 +104,8 @@ COPY --link . ./
ENV SPC_DEFAULT_C_FLAGS='-fPIE -fPIC -O3'
ENV SPC_LIBC='musl'
ENV SPC_CMD_VAR_PHP_MAKE_EXTRA_LDFLAGS_PROGRAM='-Wl,-O3 -pie'
ENV SPC_OPT_BUILD_ARGS='--with-config-file-path=/etc/frankenphp --with-config-file-scan-dir=/etc/frankenphp/php.d'
# Keep default config paths and append any externally provided SPC_OPT_BUILD_ARGS (e.g., from CI)
ENV SPC_OPT_BUILD_ARGS="--with-config-file-path=/etc/frankenphp --with-config-file-scan-dir=/etc/frankenphp/php.d ${SPC_OPT_BUILD_ARGS}"
ENV SPC_REL_TYPE='binary'
ENV EXTENSION_DIR='/usr/lib/frankenphp/modules'

32
testdata/integration/basic_function.go vendored Normal file
View File

@@ -0,0 +1,32 @@
package testintegration
// #include <Zend/zend_types.h>
import "C"
import (
"strings"
"unsafe"
"github.com/dunglas/frankenphp"
)
// export_php:function test_uppercase(string $str): string
func test_uppercase(s *C.zend_string) unsafe.Pointer {
str := frankenphp.GoString(unsafe.Pointer(s))
upper := strings.ToUpper(str)
return frankenphp.PHPString(upper, false)
}
// export_php:function test_add_numbers(int $a, int $b): int
func test_add_numbers(a int64, b int64) int64 {
return a + b
}
// export_php:function test_multiply(float $a, float $b): float
func test_multiply(a float64, b float64) float64 {
return a * b
}
// export_php:function test_is_enabled(bool $flag): bool
func test_is_enabled(flag bool) bool {
return !flag
}

64
testdata/integration/callable.go vendored Normal file
View File

@@ -0,0 +1,64 @@
package testintegration
// #include <Zend/zend_types.h>
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
// export_php:function my_array_map(array $data, callable $callback): array
func my_array_map(arr *C.zend_array, callback *C.zval) unsafe.Pointer {
goArray, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr))
if err != nil {
return nil
}
result := make([]any, len(goArray))
for i, item := range goArray {
callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{item})
result[i] = callResult
}
return frankenphp.PHPPackedArray[any](result)
}
// export_php:function my_filter(array $data, ?callable $callback): array
func my_filter(arr *C.zend_array, callback *C.zval) unsafe.Pointer {
goArray, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr))
if err != nil {
return nil
}
if callback == nil {
return unsafe.Pointer(arr)
}
result := make([]any, 0)
for _, item := range goArray {
callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{item})
if boolResult, ok := callResult.(bool); ok && boolResult {
result = append(result, item)
}
}
return frankenphp.PHPPackedArray[any](result)
}
// export_php:class Processor
type Processor struct{}
// export_php:method Processor::transform(string $input, callable $transformer): string
func (p *Processor) Transform(input *C.zend_string, callback *C.zval) unsafe.Pointer {
goInput := frankenphp.GoString(unsafe.Pointer(input))
callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{goInput})
resultStr, ok := callResult.(string)
if !ok {
return unsafe.Pointer(input)
}
return frankenphp.PHPString(resultStr, false)
}

72
testdata/integration/class_methods.go vendored Normal file
View File

@@ -0,0 +1,72 @@
package testintegration
// #include <Zend/zend_types.h>
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
// export_php:class Counter
type CounterStruct struct {
Value int
}
// export_php:method Counter::increment(): void
func (c *CounterStruct) Increment() {
c.Value++
}
// export_php:method Counter::decrement(): void
func (c *CounterStruct) Decrement() {
c.Value--
}
// export_php:method Counter::getValue(): int
func (c *CounterStruct) GetValue() int64 {
return int64(c.Value)
}
// export_php:method Counter::setValue(int $value): void
func (c *CounterStruct) SetValue(value int64) {
c.Value = int(value)
}
// export_php:method Counter::reset(): void
func (c *CounterStruct) Reset() {
c.Value = 0
}
// export_php:method Counter::addValue(int $amount): int
func (c *CounterStruct) AddValue(amount int64) int64 {
c.Value += int(amount)
return int64(c.Value)
}
// export_php:method Counter::updateWithNullable(?int $newValue): void
func (c *CounterStruct) UpdateWithNullable(newValue *int64) {
if newValue != nil {
c.Value = int(*newValue)
}
}
// export_php:class StringHolder
type StringHolderStruct struct {
Data string
}
// export_php:method StringHolder::setData(string $data): void
func (sh *StringHolderStruct) SetData(data *C.zend_string) {
sh.Data = frankenphp.GoString(unsafe.Pointer(data))
}
// export_php:method StringHolder::getData(): string
func (sh *StringHolderStruct) GetData() unsafe.Pointer {
return frankenphp.PHPString(sh.Data, false)
}
// export_php:method StringHolder::getLength(): int
func (sh *StringHolderStruct) GetLength() int64 {
return int64(len(sh.Data))
}

70
testdata/integration/constants.go vendored Normal file
View File

@@ -0,0 +1,70 @@
package testintegration
// #include <Zend/zend_types.h>
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
// export_php:const
const TEST_MAX_RETRIES = 100
// export_php:const
const TEST_API_VERSION = "2.0.0"
// export_php:const
const TEST_ENABLED = true
// export_php:const
const TEST_PI = 3.14159
// export_php:const
const STATUS_PENDING = iota
// export_php:const
const STATUS_PROCESSING = iota
// export_php:const
const STATUS_COMPLETED = iota
// export_php:class Config
type ConfigStruct struct {
Mode int
}
// export_php:classconst Config
const MODE_DEBUG = 1
// export_php:classconst Config
const MODE_PRODUCTION = 2
// export_php:classconst Config
const DEFAULT_TIMEOUT = 30
// export_php:method Config::setMode(int $mode): void
func (c *ConfigStruct) SetMode(mode int64) {
c.Mode = int(mode)
}
// export_php:method Config::getMode(): int
func (c *ConfigStruct) GetMode() int64 {
return int64(c.Mode)
}
// export_php:function test_with_constants(int $status): string
func test_with_constants(status int64) unsafe.Pointer {
var result string
switch status {
case STATUS_PENDING:
result = "pending"
case STATUS_PROCESSING:
result = "processing"
case STATUS_COMPLETED:
result = "completed"
default:
result = "unknown"
}
return frankenphp.PHPString(result, false)
}

View File

@@ -0,0 +1,9 @@
package testintegration
// #include <Zend/zend_types.h>
import "C"
// export_php:function invalid_return_type(string $str): unsupported_type
func invalid_return_type(s *C.zend_string) int {
return 42
}

50
testdata/integration/namespace.go vendored Normal file
View File

@@ -0,0 +1,50 @@
package testintegration
// export_php:namespace TestIntegration\Extension
// #include <Zend/zend_types.h>
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
// export_php:const
const NAMESPACE_VERSION = "1.0.0"
// export_php:function greet(string $name): string
func greet(name *C.zend_string) unsafe.Pointer {
str := frankenphp.GoString(unsafe.Pointer(name))
result := "Hello, " + str + "!"
return frankenphp.PHPString(result, false)
}
// export_php:class Person
type PersonStruct struct {
Name string
Age int
}
// export_php:method Person::setName(string $name): void
func (p *PersonStruct) SetName(name *C.zend_string) {
p.Name = frankenphp.GoString(unsafe.Pointer(name))
}
// export_php:method Person::getName(): string
func (p *PersonStruct) GetName() unsafe.Pointer {
return frankenphp.PHPString(p.Name, false)
}
// export_php:method Person::setAge(int $age): void
func (p *PersonStruct) SetAge(age int64) {
p.Age = int(age)
}
// export_php:method Person::getAge(): int
func (p *PersonStruct) GetAge() int64 {
return int64(p.Age)
}
// export_php:classconst Person
const DEFAULT_AGE = 18

19
testdata/integration/type_mismatch.go vendored Normal file
View File

@@ -0,0 +1,19 @@
package testintegration
// #include <Zend/zend_types.h>
import "C"
// export_php:function mismatched_param_type(int $value): int
func mismatched_param_type(value string) int64 {
return 0
}
// export_php:class BadClass
type BadClassStruct struct {
Value int
}
// export_php:method BadClass::wrongReturnType(): string
func (bc *BadClassStruct) WrongReturnType() int {
return bc.Value
}

21
testdata/log-frankenphp_log.php vendored Normal file
View File

@@ -0,0 +1,21 @@
<?php
require_once __DIR__.'/_executor.php';
frankenphp_log("default level message");
return function () {
frankenphp_log("some debug message {$_GET['i']}", FRANKENPHP_LOG_LEVEL_DEBUG, [
"key int" => 1,
]);
frankenphp_log("some info message {$_GET['i']}", FRANKENPHP_LOG_LEVEL_INFO, [
"key string" => "string",
]);
frankenphp_log("some warn message {$_GET['i']}", FRANKENPHP_LOG_LEVEL_WARN);
frankenphp_log("some error message {$_GET['i']}", FRANKENPHP_LOG_LEVEL_ERROR, [
"err" => ["a", "v"],
]);
};

View File

@@ -3,17 +3,17 @@
do {
$ok = frankenphp_handle_request(function (): void {
print_r($_SERVER);
if (!isset($_SERVER['HTTP_REQUEST'])) {
exit(1);
}
if (isset($_SERVER['FRANKENPHP_WORKER'])) {
exit(2);
}
if (isset($_SERVER['FOO'])) {
exit(3);
}
});
getopt('abc');
if (!isset($_SERVER['HTTP_REQUEST'])) {
exit(1);
}
if (isset($_SERVER['FRANKENPHP_WORKER'])) {
exit(2);
}
if (isset($_SERVER['FOO'])) {
exit(3);
}
} while ($ok);

12
types.c
View File

@@ -16,6 +16,8 @@ Bucket *get_ht_bucket_data(HashTable *ht, uint32_t index) {
void *__emalloc__(size_t size) { return emalloc(size); }
void __efree__(void *ptr) { efree(ptr); }
void __zend_hash_init__(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor,
bool persistent) {
zend_hash_init(ht, nSize, NULL, pDestructor, persistent);
@@ -31,6 +33,16 @@ void __zval_double__(zval *zv, double val) { ZVAL_DOUBLE(zv, val); }
void __zval_string__(zval *zv, zend_string *str) { ZVAL_STR(zv, str); }
void __zval_empty_string__(zval *zv) { ZVAL_EMPTY_STRING(zv); }
void __zval_arr__(zval *zv, zend_array *arr) { ZVAL_ARR(zv, arr); }
zend_array *__zend_new_array__(uint32_t size) { return zend_new_array(size); }
int __zend_is_callable__(zval *cb) { return zend_is_callable(cb, 0, NULL); }
int __call_user_function__(zval *function_name, zval *retval,
uint32_t param_count, zval params[]) {
return call_user_function(CG(function_table), NULL, function_name, retval,
param_count, params);
}

200
types.go
View File

@@ -1,6 +1,20 @@
package frankenphp
/*
#cgo nocallback __zend_new_array__
#cgo nocallback __zval_null__
#cgo nocallback __zval_bool__
#cgo nocallback __zval_long__
#cgo nocallback __zval_double__
#cgo nocallback __zval_string__
#cgo nocallback __zval_arr__
#cgo noescape __zend_new_array__
#cgo noescape __zval_null__
#cgo noescape __zval_bool__
#cgo noescape __zval_long__
#cgo noescape __zval_double__
#cgo noescape __zval_string__
#cgo noescape __zval_arr__
#include "types.h"
*/
import "C"
@@ -13,7 +27,7 @@ import (
)
type toZval interface {
toZval() *C.zval
toZval(*C.zval)
}
// EXPERIMENTAL: GoString copies a zend_string to a Go string.
@@ -50,8 +64,8 @@ type AssociativeArray[T any] struct {
Order []string
}
func (a AssociativeArray[T]) toZval() *C.zval {
return (*C.zval)(PHPAssociativeArray[T](a))
func (a AssociativeArray[T]) toZval(zval *C.zval) {
C.__zval_arr__(zval, (*C.zend_array)(PHPAssociativeArray[T](a)))
}
// EXPERIMENTAL: GoAssociativeArray converts a zend_array to a Go AssociativeArray
@@ -61,7 +75,7 @@ func GoAssociativeArray[T any](arr unsafe.Pointer) (AssociativeArray[T], error)
return AssociativeArray[T]{entries, order}, err
}
// EXPERIMENTAL: GoMap converts a zval having a zend_array value to an unordered Go map
// EXPERIMENTAL: GoMap converts a zend_array to an unordered Go map
func GoMap[T any](arr unsafe.Pointer) (map[string]T, error) {
entries, _, err := goArray[T](arr, false)
@@ -73,27 +87,25 @@ func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, e
return nil, nil, errors.New("received a nil pointer on array conversion")
}
zval := (*C.zval)(arr)
v, err := extractZvalValue(zval, C.IS_ARRAY)
if err != nil {
return nil, nil, fmt.Errorf("received a *zval that wasn't a HashTable on array conversion: %w", err)
array := (*C.zend_array)(arr)
if array == nil {
return nil, nil, fmt.Errorf("received a *zval that wasn't a HashTable on array conversion")
}
hashTable := (*C.HashTable)(v)
nNumUsed := hashTable.nNumUsed
nNumUsed := array.nNumUsed
entries := make(map[string]T, nNumUsed)
var order []string
if ordered {
order = make([]string, 0, nNumUsed)
}
if htIsPacked(hashTable) {
// if the HashTable is packed, convert all integer keys to strings
if htIsPacked(array) {
// if the array is packed, convert all integer keys to strings
// this is probably a bug by the dev using this function
// still, we'll (inefficiently) convert to an associative array
for i := C.uint32_t(0); i < nNumUsed; i++ {
v := C.get_ht_packed_data(hashTable, i)
v := C.get_ht_packed_data(array, i)
if v != nil && C.zval_get_type(v) != C.IS_UNDEF {
strIndex := strconv.Itoa(int(i))
e, err := goValue[T](v)
@@ -114,7 +126,7 @@ func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, e
var zeroVal T
for i := C.uint32_t(0); i < nNumUsed; i++ {
bucket := C.get_ht_bucket_data(hashTable, i)
bucket := C.get_ht_bucket_data(array, i)
if bucket == nil || C.zval_get_type(&bucket.val) == C.IS_UNDEF {
continue
}
@@ -150,26 +162,24 @@ func goArray[T any](arr unsafe.Pointer, ordered bool) (map[string]T, []string, e
return entries, order, nil
}
// EXPERIMENTAL: GoPackedArray converts a zval with a zend_array value to a Go slice
// EXPERIMENTAL: GoPackedArray converts a zend_array to a Go slice
func GoPackedArray[T any](arr unsafe.Pointer) ([]T, error) {
if arr == nil {
return nil, errors.New("GoPackedArray received a nil value")
}
zval := (*C.zval)(arr)
v, err := extractZvalValue(zval, C.IS_ARRAY)
if err != nil {
return nil, fmt.Errorf("GoPackedArray received *zval that wasn't a HashTable: %w", err)
array := (*C.zend_array)(arr)
if array == nil {
return nil, fmt.Errorf("GoPackedArray received *zval that wasn't a HashTable")
}
hashTable := (*C.HashTable)(v)
nNumUsed := hashTable.nNumUsed
nNumUsed := array.nNumUsed
result := make([]T, 0, nNumUsed)
if htIsPacked(hashTable) {
if htIsPacked(array) {
for i := C.uint32_t(0); i < nNumUsed; i++ {
v := C.get_ht_packed_data(hashTable, i)
v := C.get_ht_packed_data(array, i)
if v != nil && C.zval_get_type(v) != C.IS_UNDEF {
v, err := goValue[T](v)
if err != nil {
@@ -185,7 +195,7 @@ func GoPackedArray[T any](arr unsafe.Pointer) ([]T, error) {
// fallback if ht isn't packed - equivalent to array_values()
for i := C.uint32_t(0); i < nNumUsed; i++ {
bucket := C.get_ht_bucket_data(hashTable, i)
bucket := C.get_ht_bucket_data(array, i)
if bucket != nil && C.zval_get_type(&bucket.val) != C.IS_UNDEF {
v, err := goValue[T](&bucket.val)
if err != nil {
@@ -199,18 +209,18 @@ func GoPackedArray[T any](arr unsafe.Pointer) ([]T, error) {
return result, nil
}
// EXPERIMENTAL: PHPMap converts an unordered Go map to a PHP zend_array
// EXPERIMENTAL: PHPMap converts an unordered Go map to a zend_array
func PHPMap[T any](arr map[string]T) unsafe.Pointer {
return phpArray[T](arr, nil)
}
// EXPERIMENTAL: PHPAssociativeArray converts a Go AssociativeArray to a PHP zval with a zend_array value
// EXPERIMENTAL: PHPAssociativeArray converts a Go AssociativeArray to a zend_array
func PHPAssociativeArray[T any](arr AssociativeArray[T]) unsafe.Pointer {
return phpArray[T](arr.Map, arr.Order)
}
func phpArray[T any](entries map[string]T, order []string) unsafe.Pointer {
var zendArray *C.HashTable
var zendArray *C.zend_array
if len(order) != 0 {
zendArray = createNewArray((uint32)(len(order)))
@@ -218,19 +228,18 @@ func phpArray[T any](entries map[string]T, order []string) unsafe.Pointer {
val := entries[key]
zval := phpValue(val)
C.zend_hash_str_update(zendArray, toUnsafeChar(key), C.size_t(len(key)), zval)
C.__efree__(unsafe.Pointer(zval))
}
} else {
zendArray = createNewArray((uint32)(len(entries)))
for key, val := range entries {
zval := phpValue(val)
C.zend_hash_str_update(zendArray, toUnsafeChar(key), C.size_t(len(key)), zval)
C.__efree__(unsafe.Pointer(zval))
}
}
var zval C.zval
C.__zval_arr__(&zval, zendArray)
return unsafe.Pointer(&zval)
return unsafe.Pointer(zendArray)
}
// EXPERIMENTAL: PHPPackedArray converts a Go slice to a PHP zval with a zend_array value.
@@ -239,12 +248,10 @@ func PHPPackedArray[T any](slice []T) unsafe.Pointer {
for _, val := range slice {
zval := phpValue(val)
C.zend_hash_next_index_insert(zendArray, zval)
C.__efree__(unsafe.Pointer(zval))
}
var zval C.zval
C.__zval_arr__(&zval, zendArray)
return unsafe.Pointer(&zval)
return unsafe.Pointer(zendArray)
}
// EXPERIMENTAL: GoValue converts a PHP zval to a Go value
@@ -316,11 +323,11 @@ func goValue[T any](zval *C.zval) (res T, err error) {
return resZero, err
}
hashTable := (*C.HashTable)(v)
if hashTable != nil && htIsPacked(hashTable) {
array := (*C.zend_array)(v)
if array != nil && htIsPacked(array) {
typ := reflect.TypeOf(res)
if typ == nil || typ.Kind() == reflect.Interface && typ.NumMethod() == 0 {
r, e := GoPackedArray[any](unsafe.Pointer(zval))
r, e := GoPackedArray[any](unsafe.Pointer(array))
if e != nil {
return resZero, e
}
@@ -333,7 +340,7 @@ func goValue[T any](zval *C.zval) (res T, err error) {
return resZero, fmt.Errorf("cannot convert packed array to non-any Go type %s", typ.String())
}
a, err := GoAssociativeArray[T](unsafe.Pointer(zval))
a, err := GoAssociativeArray[T](unsafe.Pointer(array))
if err != nil {
return resZero, err
}
@@ -364,45 +371,63 @@ func PHPValue(value any) unsafe.Pointer {
}
func phpValue(value any) *C.zval {
var zval C.zval
zval := (*C.zval)(C.__emalloc__(C.size_t(unsafe.Sizeof(C.zval{}))))
if toZvalObj, ok := value.(toZval); ok {
return toZvalObj.toZval()
toZvalObj.toZval(zval)
return zval
}
switch v := value.(type) {
case nil:
C.__zval_null__(&zval)
C.__zval_null__(zval)
case bool:
C.__zval_bool__(&zval, C._Bool(v))
C.__zval_bool__(zval, C._Bool(v))
case int:
C.__zval_long__(&zval, C.zend_long(v))
C.__zval_long__(zval, C.zend_long(v))
case int64:
C.__zval_long__(&zval, C.zend_long(v))
C.__zval_long__(zval, C.zend_long(v))
case float64:
C.__zval_double__(&zval, C.double(v))
C.__zval_double__(zval, C.double(v))
case string:
if v == "" {
C.__zval_empty_string__(zval)
break
}
str := (*C.zend_string)(PHPString(v, false))
C.__zval_string__(&zval, str)
C.__zval_string__(zval, str)
case AssociativeArray[any]:
C.__zval_arr__(zval, (*C.zend_array)(PHPAssociativeArray[any](v)))
case map[string]any:
return (*C.zval)(PHPAssociativeArray[any](AssociativeArray[any]{Map: v}))
C.__zval_arr__(zval, (*C.zend_array)(PHPMap[any](v)))
case []any:
return (*C.zval)(PHPPackedArray(v))
C.__zval_arr__(zval, (*C.zend_array)(PHPPackedArray[any](v)))
default:
C.__efree__(unsafe.Pointer(zval))
panic(fmt.Sprintf("unsupported Go type %T", v))
}
return &zval
return zval
}
// createNewArray creates a new zend_array with the specified size.
func createNewArray(size uint32) *C.HashTable {
func createNewArray(size uint32) *C.zend_array {
arr := C.__zend_new_array__(C.uint32_t(size))
return (*C.HashTable)(unsafe.Pointer(arr))
return (*C.zend_array)(unsafe.Pointer(arr))
}
// htIsPacked checks if a HashTable is a list (packed) or hashmap (not packed).
func htIsPacked(ht *C.HashTable) bool {
// IsPacked determines if the given zend_array is a packed array (list).
// Returns false if the array is nil or not packed.
func IsPacked(arr unsafe.Pointer) bool {
if arr == nil {
return false
}
return htIsPacked((*C.zend_array)(arr))
}
// htIsPacked checks if a zend_array is a list (packed) or hashmap (not packed).
func htIsPacked(ht *C.zend_array) bool {
flags := *(*C.uint32_t)(unsafe.Pointer(&ht.u[0]))
return (flags & C.HASH_FLAG_PACKED) != 0
@@ -435,3 +460,66 @@ func extractZvalValue(zval *C.zval, expectedType C.uint8_t) (unsafe.Pointer, err
return nil, fmt.Errorf("unsupported zval type %d", expectedType)
}
func zendStringRelease(p unsafe.Pointer) {
zs := (*C.zend_string)(p)
C.zend_string_release(zs)
}
func zendHashDestroy(p unsafe.Pointer) {
ht := (*C.zend_array)(p)
C.zend_hash_destroy(ht)
}
// EXPERIMENTAL: CallPHPCallable executes a PHP callable with the given parameters.
// Returns the result of the callable as a Go interface{}, or nil if the call failed.
func CallPHPCallable(cb unsafe.Pointer, params []interface{}) interface{} {
if cb == nil {
return nil
}
callback := (*C.zval)(cb)
if callback == nil {
return nil
}
if C.__zend_is_callable__(callback) == 0 {
return nil
}
paramCount := len(params)
var paramStorage *C.zval
if paramCount > 0 {
paramStorage = (*C.zval)(C.__emalloc__(C.size_t(paramCount) * C.size_t(unsafe.Sizeof(C.zval{}))))
defer func() {
for i := 0; i < paramCount; i++ {
targetZval := (*C.zval)(unsafe.Pointer(uintptr(unsafe.Pointer(paramStorage)) + uintptr(i)*unsafe.Sizeof(C.zval{})))
C.zval_ptr_dtor(targetZval)
}
C.__efree__(unsafe.Pointer(paramStorage))
}()
for i, param := range params {
targetZval := (*C.zval)(unsafe.Pointer(uintptr(unsafe.Pointer(paramStorage)) + uintptr(i)*unsafe.Sizeof(C.zval{})))
sourceZval := phpValue(param)
*targetZval = *sourceZval
C.__efree__(unsafe.Pointer(sourceZval))
}
}
var retval C.zval
result := C.__call_user_function__(callback, &retval, C.uint32_t(paramCount), paramStorage)
if result != C.SUCCESS {
return nil
}
goResult, err := goValue[any](&retval)
C.zval_ptr_dtor(&retval)
if err != nil {
return nil
}
return goResult
}

View File

@@ -11,14 +11,20 @@ zval *get_ht_packed_data(HashTable *, uint32_t index);
Bucket *get_ht_bucket_data(HashTable *, uint32_t index);
void *__emalloc__(size_t size);
void __efree__(void *ptr);
void __zend_hash_init__(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor,
bool persistent);
int __zend_is_callable__(zval *cb);
int __call_user_function__(zval *function_name, zval *retval,
uint32_t param_count, zval params[]);
void __zval_null__(zval *zv);
void __zval_bool__(zval *zv, bool val);
void __zval_long__(zval *zv, zend_long val);
void __zval_double__(zval *zv, double val);
void __zval_string__(zval *zv, zend_string *str);
void __zval_empty_string__(zval *zv);
void __zval_arr__(zval *zv, zend_array *arr);
zend_array *__zend_new_array__(uint32_t size);

View File

@@ -1,7 +1,6 @@
package frankenphp
import (
"io"
"log/slog"
"testing"
@@ -13,7 +12,8 @@ import (
// this is necessary if tests make use of PHP's internal allocation
func testOnDummyPHPThread(t *testing.T, test func()) {
t.Helper()
globalLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
globalLogger = slog.Default()
_, err := initPHPThreads(1, 1, nil) // boot 1 thread
assert.NoError(t, err)
handler := convertToTaskThread(phpThreads[0])
@@ -29,9 +29,10 @@ func TestGoString(t *testing.T) {
testOnDummyPHPThread(t, func() {
originalString := "Hello, World!"
convertedString := GoString(PHPString(originalString, false))
phpString := PHPString(originalString, false)
defer zendStringRelease(phpString)
assert.Equal(t, originalString, convertedString, "string -> zend_string -> string should yield an equal string")
assert.Equal(t, originalString, GoString(phpString), "string -> zend_string -> string should yield an equal string")
})
}
@@ -42,7 +43,9 @@ func TestPHPMap(t *testing.T) {
"foo2": "bar2",
}
convertedMap, err := GoMap[string](PHPMap(originalMap))
phpArray := PHPMap(originalMap)
defer zendHashDestroy(phpArray)
convertedMap, err := GoMap[string](phpArray)
require.NoError(t, err)
assert.Equal(t, originalMap, convertedMap, "associative array should be equal after conversion")
@@ -59,7 +62,9 @@ func TestOrderedPHPAssociativeArray(t *testing.T) {
Order: []string{"foo2", "foo1"},
}
convertedArray, err := GoAssociativeArray[string](PHPAssociativeArray(originalArray))
phpArray := PHPAssociativeArray(originalArray)
defer zendHashDestroy(phpArray)
convertedArray, err := GoAssociativeArray[string](phpArray)
require.NoError(t, err)
assert.Equal(t, originalArray, convertedArray, "associative array should be equal after conversion")
@@ -70,7 +75,9 @@ func TestPHPPackedArray(t *testing.T) {
testOnDummyPHPThread(t, func() {
originalSlice := []string{"bar1", "bar2"}
convertedSlice, err := GoPackedArray[string](PHPPackedArray(originalSlice))
phpArray := PHPPackedArray(originalSlice)
defer zendHashDestroy(phpArray)
convertedSlice, err := GoPackedArray[string](phpArray)
require.NoError(t, err)
assert.Equal(t, originalSlice, convertedSlice, "slice should be equal after conversion")
@@ -85,7 +92,9 @@ func TestPHPPackedArrayToGoMap(t *testing.T) {
"1": "bar2",
}
convertedMap, err := GoMap[string](PHPPackedArray(originalSlice))
phpArray := PHPPackedArray(originalSlice)
defer zendHashDestroy(phpArray)
convertedMap, err := GoMap[string](phpArray)
require.NoError(t, err)
assert.Equal(t, expectedMap, convertedMap, "convert a packed to an associative array")
@@ -103,7 +112,9 @@ func TestPHPAssociativeArrayToPacked(t *testing.T) {
}
expectedSlice := []string{"bar1", "bar2"}
convertedSlice, err := GoPackedArray[string](PHPAssociativeArray(originalArray))
phpArray := PHPAssociativeArray(originalArray)
defer zendHashDestroy(phpArray)
convertedSlice, err := GoPackedArray[string](phpArray)
require.NoError(t, err)
assert.Equal(t, expectedSlice, convertedSlice, "convert an associative array to a slice")
@@ -126,7 +137,9 @@ func TestNestedMixedArray(t *testing.T) {
},
}
convertedArray, err := GoMap[any](PHPMap(originalArray))
phpArray := PHPMap(originalArray)
defer zendHashDestroy(phpArray)
convertedArray, err := GoMap[any](phpArray)
require.NoError(t, err)
assert.Equal(t, originalArray, convertedArray, "nested mixed array should be equal after conversion")

23
watcher-skip.go Normal file
View File

@@ -0,0 +1,23 @@
//go:build nowatcher
package frankenphp
import "errors"
type hotReloadOpt struct {
}
var errWatcherNotEnabled = errors.New("watcher support is not enabled")
func initWatchers(o *opt) error {
for _, o := range o.workers {
if len(o.watch) != 0 {
return errWatcherNotEnabled
}
}
return nil
}
func drainWatchers() {
}

47
watcher.go Normal file
View File

@@ -0,0 +1,47 @@
//go:build !nowatcher
package frankenphp
import (
"sync/atomic"
"github.com/dunglas/frankenphp/internal/watcher"
watcherGo "github.com/e-dant/watcher/watcher-go"
)
type hotReloadOpt struct {
hotReload []*watcher.PatternGroup
}
var restartWorkers atomic.Bool
func initWatchers(o *opt) error {
watchPatterns := make([]*watcher.PatternGroup, 0, len(o.hotReload))
for _, o := range o.workers {
if len(o.watch) == 0 {
continue
}
watcherIsEnabled = true
watchPatterns = append(watchPatterns, &watcher.PatternGroup{Patterns: o.watch, Callback: func(_ []*watcherGo.Event) {
restartWorkers.Store(true)
}})
}
if watcherIsEnabled {
watchPatterns = append(watchPatterns, &watcher.PatternGroup{
Callback: func(_ []*watcherGo.Event) {
if restartWorkers.Swap(false) {
RestartWorkers()
}
},
})
}
return watcher.InitWatcher(globalCtx, globalLogger, append(watchPatterns, o.hotReload...))
}
func drainWatchers() {
watcher.DrainWatcher()
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// we have to wait a few milliseconds for the watcher debounce to take effect
@@ -41,6 +42,8 @@ func TestWorkersShouldNotReloadOnExcludingPattern(t *testing.T) {
}
func pollForWorkerReset(t *testing.T, handler func(http.ResponseWriter, *http.Request), limit int) bool {
t.Helper()
// first we make an initial request to start the request counter
body, _ := testGet("http://example.com/worker-with-counter.php", handler, t)
assert.Equal(t, "requests:1", body)
@@ -54,18 +57,19 @@ func pollForWorkerReset(t *testing.T, handler func(http.ResponseWriter, *http.Re
return true
}
}
return false
}
func updateTestFile(fileName string, content string, t *testing.T) {
absFileName, err := filepath.Abs(fileName)
assert.NoError(t, err)
require.NoError(t, err)
dirName := filepath.Dir(absFileName)
if _, err := os.Stat(dirName); os.IsNotExist(err) {
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)
require.NoError(t, err)
require.NoError(t, os.WriteFile(absFileName, []byte(content), 0644))
}

View File

@@ -14,11 +14,12 @@ import (
"github.com/dunglas/frankenphp/internal/fastabs"
"github.com/dunglas/frankenphp/internal/state"
"github.com/dunglas/frankenphp/internal/watcher"
)
// represents a worker script and can have many threads assigned to it
type worker struct {
mercureContext
name string
fileName string
num int
@@ -37,25 +38,31 @@ type worker struct {
var (
workers []*worker
watcherIsEnabled bool
startupFailChan chan (error)
startupFailChan chan error
)
func initWorkers(opt []workerOpt) error {
if len(opt) == 0 {
return nil
}
var (
workersReady sync.WaitGroup
totalThreadsToStart int
)
workers = make([]*worker, 0, len(opt))
directoriesToWatch := getDirectoriesToWatch(opt)
watcherIsEnabled = len(directoriesToWatch) > 0
totalThreadsToStart := 0
for _, o := range opt {
w, err := newWorker(o)
if err != nil {
return err
}
totalThreadsToStart += w.num
workers = append(workers, w)
}
var workersReady sync.WaitGroup
startupFailChan = make(chan error, totalThreadsToStart)
for _, w := range workers {
@@ -73,22 +80,13 @@ func initWorkers(opt []workerOpt) error {
select {
case err := <-startupFailChan:
// at least 1 worker has failed, shut down and return an error
Shutdown()
// at least 1 worker has failed, return an error
return fmt.Errorf("failed to initialize workers: %w", err)
default:
// all workers started successfully
startupFailChan = nil
}
if !watcherIsEnabled {
return nil
}
if err := watcher.InitWatcher(globalCtx, directoriesToWatch, RestartWorkers, globalLogger); err != nil {
return err
}
return nil
}
@@ -156,6 +154,8 @@ func newWorker(o workerOpt) (*worker, error) {
onThreadShutdown: o.onThreadShutdown,
}
w.configureMercure(&o)
w.requestOptions = append(
w.requestOptions,
WithRequestDocumentRoot(filepath.Dir(o.fileName), false),
@@ -175,39 +175,43 @@ func DrainWorkers() {
}
func drainWorkerThreads() []*phpThread {
ready := sync.WaitGroup{}
drainedThreads := make([]*phpThread, 0)
var (
ready sync.WaitGroup
drainedThreads []*phpThread
)
for _, worker := range workers {
worker.threadMutex.RLock()
ready.Add(len(worker.threads))
for _, thread := range worker.threads {
if !thread.state.RequestSafeStateChange(state.Restarting) {
ready.Done()
// no state change allowed == thread is shutting down
// we'll proceed to restart all other threads anyways
// we'll proceed to restart all other threads anyway
continue
}
close(thread.drainChan)
drainedThreads = append(drainedThreads, thread)
go func(thread *phpThread) {
thread.state.WaitFor(state.Yielding)
ready.Done()
}(thread)
}
worker.threadMutex.RUnlock()
}
ready.Wait()
return drainedThreads
}
func drainWatcher() {
if watcherIsEnabled {
watcher.DrainWatcher()
}
}
// RestartWorkers attempts to restart all workers gracefully
// All workers must be restarted at the same time to prevent issues with opcache resetting.
func RestartWorkers() {
// disallow scaling threads while restarting workers
scalingMu.Lock()
@@ -221,14 +225,6 @@ func RestartWorkers() {
}
}
func getDirectoriesToWatch(workerOpts []workerOpt) []string {
directoriesToWatch := []string{}
for _, w := range workerOpts {
directoriesToWatch = append(directoriesToWatch, w.watch...)
}
return directoriesToWatch
}
func (worker *worker) attachThread(thread *phpThread) {
worker.threadMutex.Lock()
worker.threads = append(worker.threads, thread)

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"io"
"log"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -17,9 +16,6 @@ import (
"github.com/dunglas/frankenphp"
"github.com/stretchr/testify/assert"
"go.uber.org/zap/exp/zapslog"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
func TestWorker(t *testing.T) {
@@ -97,8 +93,7 @@ func TestWorkerEnv(t *testing.T) {
}
func TestWorkerGetOpt(t *testing.T) {
obs, logs := observer.New(zapcore.InfoLevel)
logger := slog.New(zapslog.NewHandler(obs))
logger, buf := newTestLogger(t)
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/worker-getopt.php?i=%d", i), nil)
@@ -114,9 +109,7 @@ func TestWorkerGetOpt(t *testing.T) {
assert.Contains(t, string(body), fmt.Sprintf("[REQUEST_URI] => /worker-getopt.php?i=%d", i))
}, &testOptions{logger: logger, workerScript: "worker-getopt.php", env: map[string]string{"FOO": "bar"}})
for _, l := range logs.FilterFieldKey("exit_status").All() {
assert.Failf(t, "unexpected exit status", "exit status: %d", l.ContextMap()["exit_status"])
}
assert.NotRegexp(t, buf.String(), "exit_status=[1-9]")
}
func ExampleServeHTTP_workers() {

View File

@@ -10,6 +10,8 @@ import (
)
func TestWorkersExtension(t *testing.T) {
t.Cleanup(Shutdown)
readyWorkers := 0
shutdownWorkers := 0
serverStarts := 0