From bea5ef1a10fe613c538b6fe5ab1548a1c4783caf Mon Sep 17 00:00:00 2001 From: Valery Piashchynski Date: Fri, 12 Jun 2026 20:31:53 +0200 Subject: [PATCH] feature: remove IPC Signed-off-by: Valery Piashchynski --- .github/workflows/tests.yml | 17 +- Makefile | 7 +- go.mod | 5 +- go.sum | 8 +- internal/protocol.go | 94 -- ipc/pipe/pipe.go | 147 -- ipc/pipe/pipe_spawn_test.go | 412 ------ ipc/pipe/pipe_test.go | 462 ------ ipc/socket/socket.go | 194 --- ipc/socket/socket_spawn_test.go | 518 ------- ipc/socket/socket_test.go | 563 ------- payload/payload.go | 27 - pool/allocator.go | 40 +- pool/config.go | 16 - pool/static_pool/debug.go | 145 -- pool/static_pool/debug_test.go | 75 - pool/static_pool/dyn_allocator.go | 2 - pool/static_pool/dyn_allocator_test.go | 604 +------- pool/static_pool/fuzz_test.go | 43 - pool/static_pool/options.go | 6 - pool/static_pool/options_test.go | 13 - pool/static_pool/pool.go | 315 +--- pool/static_pool/pool_test.go | 1382 ++---------------- pool/static_pool/stream.go | 31 - pool/static_pool/supervisor_test.go | 851 ++--------- schema.json | 29 +- tests/allocate-failed.php | 18 - tests/broken.php | 14 - tests/client.php | 36 - tests/composer.json | 19 - tests/crc_error.php | 17 - tests/delay.php | 18 - tests/echo.php | 17 - tests/error.php | 13 - tests/exec_ttl.php | 15 - tests/failboot.php | 3 - tests/gzip-large-file.txt | 19 - tests/head.php | 17 - tests/http/client.php | 51 - tests/http/cookie.php | 334 ----- tests/http/data.php | 17 - tests/http/echo.php | 10 - tests/http/echoDelay.php | 11 - tests/http/echoerr.php | 12 - tests/http/env.php | 10 - tests/http/error.php | 9 - tests/http/error2.php | 9 - tests/http/header.php | 11 - tests/http/headers.php | 11 - tests/http/ip.php | 11 - tests/http/memleak.php | 11 - tests/http/payload.php | 18 - tests/http/pid.php | 11 - tests/http/push.php | 10 - tests/http/request-uri.php | 10 - tests/http/server.php | 11 - tests/http/slow-client.php | 52 - tests/http/stuck.php | 11 - tests/http/upload.php | 35 - tests/http/user-agent.php | 10 - tests/idle.php | 15 - tests/issue659.php | 21 - tests/memleak.php | 15 - tests/metrics-issue-571.php | 37 - tests/pid.php | 17 - tests/pipes_test_script.sh | 2 - tests/psr-worker-bench.php | 30 - tests/psr-worker-post.php | 30 - tests/psr-worker-slow.php | 29 - tests/psr-worker.php | 28 - tests/raw-error.php | 21 - tests/sample.txt | 1 - tests/should-not-be-killed.php | 19 - tests/sleep-ttl.php | 15 - tests/sleep.php | 15 - tests/sleep_short.php | 15 - tests/slow-client.php | 38 - tests/slow-destroy.php | 37 - tests/slow-pid.php | 18 - tests/slow_req.php | 27 - tests/socket_test_script.sh | 2 - tests/src/Activity/SimpleActivity.php | 63 - tests/src/Client/StartNewWorkflow.php | 23 - tests/src/Workflow/SagaWorkflow.php | 54 - tests/stop.php | 25 - tests/stream_worker.php | 34 - tests/supervised.php | 14 - tests/worker-slow-dyn.php | 31 - worker/options.go | 38 +- worker/worker.go | 551 +------ worker/worker_test.go | 153 +- worker_watcher/container/channel/vec_test.go | 2 +- worker_watcher/worker_watcher_test.go | 54 +- 93 files changed, 542 insertions(+), 7819 deletions(-) delete mode 100755 internal/protocol.go delete mode 100755 ipc/pipe/pipe.go delete mode 100644 ipc/pipe/pipe_spawn_test.go delete mode 100644 ipc/pipe/pipe_test.go delete mode 100755 ipc/socket/socket.go delete mode 100644 ipc/socket/socket_spawn_test.go delete mode 100755 ipc/socket/socket_test.go delete mode 100644 payload/payload.go delete mode 100644 pool/static_pool/debug.go delete mode 100644 pool/static_pool/debug_test.go delete mode 100644 pool/static_pool/fuzz_test.go delete mode 100644 pool/static_pool/stream.go delete mode 100644 tests/allocate-failed.php delete mode 100644 tests/broken.php delete mode 100644 tests/client.php delete mode 100644 tests/composer.json delete mode 100644 tests/crc_error.php delete mode 100644 tests/delay.php delete mode 100644 tests/echo.php delete mode 100644 tests/error.php delete mode 100644 tests/exec_ttl.php delete mode 100644 tests/failboot.php delete mode 100644 tests/gzip-large-file.txt delete mode 100644 tests/head.php delete mode 100644 tests/http/client.php delete mode 100644 tests/http/cookie.php delete mode 100644 tests/http/data.php delete mode 100644 tests/http/echo.php delete mode 100644 tests/http/echoDelay.php delete mode 100644 tests/http/echoerr.php delete mode 100644 tests/http/env.php delete mode 100644 tests/http/error.php delete mode 100644 tests/http/error2.php delete mode 100644 tests/http/header.php delete mode 100644 tests/http/headers.php delete mode 100644 tests/http/ip.php delete mode 100644 tests/http/memleak.php delete mode 100644 tests/http/payload.php delete mode 100644 tests/http/pid.php delete mode 100644 tests/http/push.php delete mode 100644 tests/http/request-uri.php delete mode 100644 tests/http/server.php delete mode 100644 tests/http/slow-client.php delete mode 100644 tests/http/stuck.php delete mode 100644 tests/http/upload.php delete mode 100644 tests/http/user-agent.php delete mode 100644 tests/idle.php delete mode 100644 tests/issue659.php delete mode 100644 tests/memleak.php delete mode 100644 tests/metrics-issue-571.php delete mode 100644 tests/pid.php delete mode 100755 tests/pipes_test_script.sh delete mode 100644 tests/psr-worker-bench.php delete mode 100644 tests/psr-worker-post.php delete mode 100644 tests/psr-worker-slow.php delete mode 100644 tests/psr-worker.php delete mode 100644 tests/raw-error.php delete mode 100644 tests/sample.txt delete mode 100644 tests/should-not-be-killed.php delete mode 100644 tests/sleep-ttl.php delete mode 100644 tests/sleep.php delete mode 100644 tests/sleep_short.php delete mode 100644 tests/slow-client.php delete mode 100644 tests/slow-destroy.php delete mode 100644 tests/slow-pid.php delete mode 100644 tests/slow_req.php delete mode 100755 tests/socket_test_script.sh delete mode 100644 tests/src/Activity/SimpleActivity.php delete mode 100644 tests/src/Client/StartNewWorkflow.php delete mode 100644 tests/src/Workflow/SagaWorkflow.php delete mode 100644 tests/stop.php delete mode 100644 tests/stream_worker.php delete mode 100644 tests/supervised.php delete mode 100644 tests/worker-slow-dyn.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13b8ac9..e507166 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,15 +5,17 @@ on: branches: [master] pull_request: +permissions: + contents: read + jobs: golang: - name: Build (Go ${{ matrix.go }}, PHP ${{ matrix.php }}, OS ${{matrix.os}}) + name: Build (Go ${{ matrix.go }}, OS ${{matrix.os}}) runs-on: ${{ matrix.os }} timeout-minutes: 60 strategy: fail-fast: false matrix: - php: ["8.5"] go: [stable] os: ["ubuntu-latest"] steps: @@ -22,17 +24,10 @@ jobs: with: go-version: ${{ matrix.go }} - - name: Set up PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 # action page: - with: - php-version: ${{ matrix.php }} - extensions: sockets - - name: Check out code uses: actions/checkout@v6 - - - name: Install Composer dependencies - run: cd tests && composer update --prefer-dist --no-progress --ansi + with: + persist-credentials: false - name: Init Go modules Cache # Docs: uses: actions/cache@v5 diff --git a/Makefile b/Makefile index f9720c2..4acd8c3 100644 --- a/Makefile +++ b/Makefile @@ -5,22 +5,19 @@ SHELL = /bin/sh test_coverage: rm -rf coverage-ci mkdir ./coverage-ci - go test -v -race -cover -tags=debug -timeout 30m -coverpkg=./... -coverprofile=./coverage-ci/pipe.out -covermode=atomic ./ipc/pipe - go test -v -race -cover -tags=debug -timeout 30m -coverpkg=./... -coverprofile=./coverage-ci/socket.out -covermode=atomic ./ipc/socket go test -v -race -cover -tags=debug -timeout 30m -coverpkg=./... -coverprofile=./coverage-ci/pool_static.out -covermode=atomic ./pool/static_pool go test -v -race -cover -tags=debug -timeout 30m -coverpkg=./... -coverprofile=./coverage-ci/worker.out -covermode=atomic ./worker go test -v -race -cover -tags=debug -timeout 30m -coverpkg=./... -coverprofile=./coverage-ci/worker_stack.out -covermode=atomic ./worker_watcher go test -v -race -cover -tags=debug -timeout 30m -coverpkg=./... -coverprofile=./coverage-ci/channel.out -covermode=atomic ./worker_watcher/container/channel go test -v -race -cover -tags=debug -timeout 30m -coverpkg=./... -coverprofile=./coverage-ci/ratelimiter.out -covermode=atomic ./pool/ratelimiter + go test -v -race -cover -tags=debug -timeout 30m -coverpkg=./... -coverprofile=./coverage-ci/fsm.out -covermode=atomic ./fsm echo 'mode: atomic' > ./coverage-ci/summary.txt tail -q -n +2 ./coverage-ci/*.out >> ./coverage-ci/summary.txt test: ## Run application tests - go test -v -race ./ipc/pipe - go test -v -race ./ipc/socket go test -v -race ./pool/static_pool go test -v -race ./worker go test -v -race ./worker_watcher go test -v -race ./worker_watcher/container/channel go test -v -race ./pool/ratelimiter - go test -v -race -fuzz=FuzzStaticPoolEcho -fuzztime=30s -tags=debug ./pool/static_pool + go test -v -race ./fsm diff --git a/go.mod b/go.mod index 151437f..d7d6587 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.26 require ( github.com/roadrunner-server/errors v1.5.0 github.com/roadrunner-server/events v1.0.1 - github.com/roadrunner-server/goridge/v4 v4.0.0-beta.2 github.com/shirou/gopsutil v3.21.11+incompatible github.com/stretchr/testify v1.11.1 golang.org/x/sync v0.21.0 @@ -15,11 +14,13 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/kr/pretty v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/sys v0.45.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5170b4e..15ba312 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,11 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -16,8 +19,7 @@ github.com/roadrunner-server/errors v1.5.0 h1:unG7LKIZrSzkCCF3YLRLA5VyqE0KKomofX github.com/roadrunner-server/errors v1.5.0/go.mod h1:g9fo/T2C13cWRDR9PW1r0ZAOSQfNhWAZawyfkGiaHuI= github.com/roadrunner-server/events v1.0.1 h1:waCkKhxhzdK3VcI1xG22l+h+0J+Nfdpxjhyy01Un+kI= github.com/roadrunner-server/events v1.0.1/go.mod h1:WZRqoEVaFm209t52EuoT7ISUtvX6BrCi6bI/7pjkVC0= -github.com/roadrunner-server/goridge/v4 v4.0.0-beta.2 h1:MgH6oiSgcl+vphsQ6JpyedkXQ/DPf8zVpn0z7rdBp10= -github.com/roadrunner-server/goridge/v4 v4.0.0-beta.2/go.mod h1:Wv9CBO9VIU92e5iZIuehLHKakXgMkOzxoT4/oHDjIUA= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= @@ -37,7 +39,9 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/protocol.go b/internal/protocol.go deleted file mode 100755 index bbc5186..0000000 --- a/internal/protocol.go +++ /dev/null @@ -1,94 +0,0 @@ -package internal - -import ( - "encoding/json" - "os" - "sync" - - "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/goridge/v4/pkg/frame" - "github.com/roadrunner-server/goridge/v4/pkg/relay" -) - -type StopCommand struct { - Stop bool `json:"stop"` -} - -type pidCommand struct { - Pid int `json:"pid"` -} - -var fPool = sync.Pool{New: func() any { - return frame.NewFrame() -}} - -func getFrame() *frame.Frame { - return fPool.Get().(*frame.Frame) -} - -func putFrame(f *frame.Frame) { - f.Reset() - fPool.Put(f) -} - -func SendControl(rl relay.Relay, payload any) error { - fr := getFrame() - defer putFrame(fr) - - fr.WriteVersion(fr.Header(), frame.Version1) - fr.WriteFlags(fr.Header(), frame.CONTROL, frame.CodecJSON) - - data, err := json.Marshal(payload) - if err != nil { - return errors.Errorf("invalid payload: %s", err) - } - - fr.WritePayloadLen(fr.Header(), uint32(len(data))) //nolint:gosec - fr.WritePayload(data) - fr.WriteCRC(fr.Header()) - - // we don't need a copy here, because frame copies the data before send - err = rl.Send(fr) - if err != nil { - return err - } - - return nil -} - -func Pid(rl relay.Relay) (int64, error) { - err := SendControl(rl, pidCommand{Pid: os.Getpid()}) - if err != nil { - return 0, err - } - - fr := getFrame() - defer putFrame(fr) - - err = rl.Receive(fr) - if err != nil { - return 0, err - } - - if fr == nil { - return 0, errors.Str("nil frame received") - } - - flags := fr.ReadFlags() - - if flags&frame.CONTROL == 0 { - return 0, errors.Str("unexpected response, header is missing, no CONTROL flag") - } - - link := &pidCommand{} - err = json.Unmarshal(fr.Payload(), link) - if err != nil { - return 0, err - } - - if link.Pid <= 0 { - return 0, errors.Str("pid should be greater than 0") - } - - return int64(link.Pid), nil -} diff --git a/ipc/pipe/pipe.go b/ipc/pipe/pipe.go deleted file mode 100755 index 9994b9b..0000000 --- a/ipc/pipe/pipe.go +++ /dev/null @@ -1,147 +0,0 @@ -package pipe - -import ( - "context" - "os/exec" - - "log/slog" - - "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/goridge/v4/pkg/pipe" - "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/internal" - "github.com/roadrunner-server/pool/v2/worker" -) - -// Factory connects to stack using standard -// streams (STDIN, STDOUT pipes). -type Factory struct { - log *slog.Logger -} - -// NewPipeFactory returns new factory instance and starts -// listening -func NewPipeFactory(log *slog.Logger) *Factory { - return &Factory{ - log: log, - } -} - -type sr struct { - w *worker.Process - err error -} - -// SpawnWorkerWithContext Creates a new Process and connects it to goridge relay, -// method Wait() must be handled on the level above. -func (f *Factory) SpawnWorkerWithContext(ctx context.Context, cmd *exec.Cmd, options ...worker.Options) (*worker.Process, error) { - spCh := make(chan sr, 1) - go func() { - w, err := worker.InitBaseWorker(cmd, options...) - if err != nil { - select { - case spCh <- sr{ - w: nil, - err: err, - }: - return - default: - return - } - } - - in, err := cmd.StdoutPipe() - if err != nil { - select { - case spCh <- sr{ - w: nil, - err: err, - }: - return - default: - return - } - } - - out, err := cmd.StdinPipe() - if err != nil { - select { - case spCh <- sr{ - w: nil, - err: err, - }: - return - default: - return - } - } - - // Init new PIPE relay - relay := pipe.NewPipeRelay(in, out) - w.AttachRelay(relay) - - // Start the worker - err = w.Start() - if err != nil { - select { - case spCh <- sr{ - w: nil, - err: err, - }: - return - default: - return - } - } - - // used as a ping - _, err = internal.Pid(relay) - if err != nil { - go func() { - _ = w.Wait() - }() - _ = w.Kill() - select { - case spCh <- sr{ - w: nil, - err: err, - }: - return - default: - _ = w.Kill() - return - } - } - - // everything ok, set ready state - w.State().Transition(fsm.StateReady) - - select { - case - // return worker - spCh <- sr{ - w: w, - err: nil, - }: - return - default: - _ = w.Kill() - return - } - }() - - select { - case <-ctx.Done(): - return nil, errors.E(errors.TimeOut) - case res := <-spCh: - if res.err != nil { - return nil, res.err - } - return res.w, nil - } -} - -// Close the factory. -func (f *Factory) Close() error { - return nil -} diff --git a/ipc/pipe/pipe_spawn_test.go b/ipc/pipe/pipe_spawn_test.go deleted file mode 100644 index b905376..0000000 --- a/ipc/pipe/pipe_spawn_test.go +++ /dev/null @@ -1,412 +0,0 @@ -package pipe - -import ( - "os/exec" - "sync" - "testing" - "time" - - "log/slog" - - "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/payload" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var log = slog.New(slog.DiscardHandler) - -func Test_GetState2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - go func() { - assert.NoError(t, w.Wait()) - assert.Equal(t, fsm.StateStopped, w.State().CurrentState()) - }() - - assert.NoError(t, err) - assert.NotNil(t, w) - - assert.Equal(t, fsm.StateReady, w.State().CurrentState()) - assert.NoError(t, w.Stop()) -} - -func Test_Kill2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - wg := &sync.WaitGroup{} - wg.Go(func() { - assert.Error(t, w.Wait()) - assert.Equal(t, fsm.StateErrored, w.State().CurrentState()) - }) - - assert.NoError(t, err) - assert.NotNil(t, w) - - assert.Equal(t, fsm.StateReady, w.State().CurrentState()) - err = w.Kill() - if err != nil { - t.Errorf("error killing the Process: error %v", err) - } - wg.Wait() -} - -func Test_Pipe_Start2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - assert.NoError(t, err) - assert.NotNil(t, w) - - go func() { - assert.NoError(t, w.Wait()) - }() - - assert.NoError(t, w.Stop()) -} - -func Test_Pipe_StartError2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - err := cmd.Start() - if err != nil { - t.Errorf("error running the command: error %v", err) - } - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Pipe_PipeError3(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - _, err := cmd.StdinPipe() - if err != nil { - t.Errorf("error creating the STDIN pipe: error %v", err) - } - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Pipe_PipeError4(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - _, err := cmd.StdinPipe() - if err != nil { - t.Errorf("error creating the STDIN pipe: error %v", err) - } - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Pipe_Failboot2(t *testing.T) { - cmd := exec.Command("php", "../../tests/failboot.php") - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - assert.Nil(t, w) - assert.Error(t, err) -} - -func Test_Pipe_Invalid2(t *testing.T) { - cmd := exec.Command("php", "../../tests/invalid.php") - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Pipe_Echo2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - assert.NoError(t, err) - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.NoError(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - go func() { - if w.Wait() != nil { - t.Fail() - } - }() - - assert.Equal(t, "hello", res.String()) - err = w.Stop() - assert.NoError(t, err) -} - -func Test_Pipe_Broken2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "broken", "pipes") - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - assert.NoError(t, err) - require.NotNil(t, w) - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.Error(t, err) - assert.Nil(t, res) - - time.Sleep(time.Second) - err = w.Stop() - assert.Error(t, err) -} - -func Benchmark_Pipe_SpawnWorker_Stop2(b *testing.B) { - f := NewPipeFactory(log) - for b.Loop() { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - w, _ := f.SpawnWorkerWithContext(b.Context(), cmd) - go func() { - if w.Wait() != nil { - b.Fail() - } - }() - - err := w.Stop() - if err != nil { - b.Errorf("error stopping the worker: error %v", err) - } - } -} - -func Benchmark_Pipe_Worker_ExecEcho2(b *testing.B) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(b.Context(), cmd) - - b.ReportAllocs() - - go func() { - err := w.Wait() - if err != nil { - b.Errorf("error waiting the worker: error %v", err) - } - }() - defer func() { - err := w.Stop() - if err != nil { - b.Errorf("error stopping the worker: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} - -func Benchmark_Pipe_Worker_ExecEcho4(b *testing.B) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - w, err := NewPipeFactory(log).SpawnWorkerWithContext(b.Context(), cmd) - if err != nil { - b.Fatal(err) - } - - defer func() { - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} - -func Benchmark_Pipe_Worker_ExecEchoWithoutContext2(b *testing.B) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - w, err := NewPipeFactory(log).SpawnWorkerWithContext(b.Context(), cmd) - if err != nil { - b.Fatal(err) - } - - defer func() { - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} - -func Test_Echo2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - if err != nil { - t.Fatal(err) - } - - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.Nil(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - assert.Equal(t, "hello", res.String()) -} - -func Test_BadPayload2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{}) - assert.NoError(t, err) - assert.NotNil(t, res) -} - -func Test_String2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - assert.Contains(t, w.String(), "php ../../tests/client.php echo pipes") - assert.Contains(t, w.String(), "ready") - assert.Contains(t, w.String(), "num_execs: 0") -} - -func Test_Echo_Slow2(t *testing.T) { - cmd := exec.Command("php", "../../tests/slow-client.php", "echo", "pipes", "10", "10") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.Nil(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - assert.Equal(t, "hello", res.String()) -} - -func Test_Broken2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "broken", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - if err != nil { - t.Fatal(err) - } - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.NotNil(t, err) - assert.Nil(t, res) - - time.Sleep(time.Second * 3) - assert.Error(t, w.Stop()) -} - -func Test_Error2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "error", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.NotNil(t, err) - assert.Nil(t, res) - - if errors.Is(errors.SoftJob, err) == false { - t.Fatal("error should be of type errors.ErrSoftJob") - } - assert.Contains(t, err.Error(), "hello") -} - -func Test_NumExecs2(t *testing.T) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(t.Context(), cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - _, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - if err != nil { - t.Errorf("fail to execute payload: error %v", err) - } - assert.Equal(t, uint64(1), w.State().NumExecs()) - w.State().Transition(fsm.StateReady) - - _, err = w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - if err != nil { - t.Errorf("fail to execute payload: error %v", err) - } - assert.Equal(t, uint64(2), w.State().NumExecs()) - w.State().Transition(fsm.StateReady) - - _, err = w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - if err != nil { - t.Errorf("fail to execute payload: error %v", err) - } - assert.Equal(t, uint64(3), w.State().NumExecs()) - w.State().Transition(fsm.StateReady) -} diff --git a/ipc/pipe/pipe_test.go b/ipc/pipe/pipe_test.go deleted file mode 100644 index 4dc0911..0000000 --- a/ipc/pipe/pipe_test.go +++ /dev/null @@ -1,462 +0,0 @@ -package pipe - -import ( - "context" - "os/exec" - "sync" - "testing" - "time" - - "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/payload" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_GetState(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - go func() { - assert.NoError(t, w.Wait()) - assert.Equal(t, fsm.StateStopped, w.State().CurrentState()) - }() - - assert.NoError(t, err) - assert.NotNil(t, w) - - assert.Equal(t, fsm.StateReady, w.State().CurrentState()) - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } -} - -func Test_Kill(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - wg := &sync.WaitGroup{} - wg.Go(func() { - assert.Error(t, w.Wait()) - assert.Equal(t, fsm.StateErrored, w.State().CurrentState()) - }) - - assert.NoError(t, err) - assert.NotNil(t, w) - - assert.Equal(t, fsm.StateReady, w.State().CurrentState()) - err = w.Kill() - if err != nil { - t.Errorf("error killing the Process: error %v", err) - } - wg.Wait() -} - -func Test_Pipe_Start(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - assert.NoError(t, err) - assert.NotNil(t, w) - - go func() { - assert.NoError(t, w.Wait()) - }() - - assert.NoError(t, w.Stop()) -} - -func Test_Pipe_StartError(t *testing.T) { - t.Parallel() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - err := cmd.Start() - if err != nil { - t.Errorf("error running the command: error %v", err) - } - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Pipe_PipeError(t *testing.T) { - t.Parallel() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - _, err := cmd.StdinPipe() - if err != nil { - t.Errorf("error creating the STDIN pipe: error %v", err) - } - - ctx := t.Context() - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Pipe_PipeError2(t *testing.T) { - t.Parallel() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - // error cause - _, err := cmd.StdinPipe() - if err != nil { - t.Errorf("error creating the STDIN pipe: error %v", err) - } - - ctx := t.Context() - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Pipe_Failboot(t *testing.T) { - cmd := exec.Command("php", "../../tests/failboot.php") - ctx := t.Context() - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - - assert.Nil(t, w) - assert.Error(t, err) -} - -func Test_Pipe_Invalid(t *testing.T) { - t.Parallel() - cmd := exec.Command("php", "../../tests/invalid.php") - ctx := t.Context() - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Pipe_Echo(t *testing.T) { - t.Parallel() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - ctx := t.Context() - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - - defer func() { - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.NoError(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - go func() { - if w.Wait() != nil { - t.Fail() - } - }() - - assert.Equal(t, "hello", res.String()) -} - -func Test_Pipe_Broken(t *testing.T) { - t.Parallel() - cmd := exec.Command("php", "../../tests/client.php", "broken", "pipes") - ctx := t.Context() - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - require.NoError(t, err) - require.NotNil(t, w) - - go func() { - errW := w.Wait() - require.Error(t, errW) - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.Error(t, err) - assert.Nil(t, res) - - time.Sleep(time.Second) - err = w.Stop() - assert.NoError(t, err) -} - -func Benchmark_Pipe_SpawnWorker_Stop(b *testing.B) { - f := NewPipeFactory(log) - for b.Loop() { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - w, _ := f.SpawnWorkerWithContext(b.Context(), cmd) - go func() { - if w.Wait() != nil { - b.Fail() - } - }() - - err := w.Stop() - if err != nil { - b.Errorf("error stopping the worker: error %v", err) - } - } -} - -func Benchmark_Pipe_Worker_ExecEcho(b *testing.B) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(b.Context(), cmd) - - b.ReportAllocs() - - go func() { - err := w.Wait() - if err != nil { - b.Errorf("error waiting the worker: error %v", err) - } - }() - defer func() { - err := w.Stop() - if err != nil { - b.Errorf("error stopping the worker: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} - -func Benchmark_Pipe_Worker_ExecEchoWithoutContext(b *testing.B) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - ctx := b.Context() - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - b.Fatal(err) - } - - defer func() { - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} - -func Test_Echo(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.Nil(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - assert.Equal(t, "hello", res.String()) -} - -func Test_BadPayload(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{}) - assert.NoError(t, err) - assert.NotNil(t, res) -} - -func Test_String(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - assert.Contains(t, w.String(), "php ../../tests/client.php echo pipes") - assert.Contains(t, w.String(), "ready") - assert.Contains(t, w.String(), "num_execs: 0") -} - -func Test_Echo_Slow(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/slow-client.php", "echo", "pipes", "10", "10") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.Nil(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - assert.Equal(t, "hello", res.String()) -} - -func Test_Broken(t *testing.T) { - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "broken", "pipes") - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.NotNil(t, err) - assert.Nil(t, res) - - time.Sleep(time.Second * 3) - assert.Error(t, w.Stop()) -} - -func Test_Error(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "error", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.NotNil(t, err) - assert.Nil(t, res) - - if errors.Is(errors.SoftJob, err) == false { - t.Fatal("error should be of type errors.ErrSoftJob") - } - assert.Contains(t, err.Error(), "hello") -} - -func Test_NumExecs(t *testing.T) { - t.Parallel() - ctx := t.Context() - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - - w, _ := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err := w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - _, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - if err != nil { - t.Errorf("fail to execute payload: error %v", err) - } - w.State().Transition(fsm.StateReady) - assert.Equal(t, uint64(1), w.State().NumExecs()) - - _, err = w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - if err != nil { - t.Errorf("fail to execute payload: error %v", err) - } - assert.Equal(t, uint64(2), w.State().NumExecs()) - w.State().Transition(fsm.StateReady) - - _, err = w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - if err != nil { - t.Errorf("fail to execute payload: error %v", err) - } - assert.Equal(t, uint64(3), w.State().NumExecs()) - w.State().Transition(fsm.StateReady) -} -func Benchmark_WorkerPipeTTL(b *testing.B) { - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - ctx := b.Context() - - w, err := NewPipeFactory(log).SpawnWorkerWithContext(ctx, cmd) - require.NoError(b, err) - - go func() { - _ = w.Wait() - }() - - b.ReportAllocs() - - for b.Loop() { - res, err := w.Exec(ctx, &payload.Payload{Body: []byte("hello")}) - assert.NoError(b, err) - assert.NotNil(b, res) - } - - b.Cleanup(func() { - assert.NoError(b, w.Stop()) - }) -} diff --git a/ipc/socket/socket.go b/ipc/socket/socket.go deleted file mode 100755 index f745d9e..0000000 --- a/ipc/socket/socket.go +++ /dev/null @@ -1,194 +0,0 @@ -package socket - -import ( - "context" - stderr "errors" - "net" - "os/exec" - "sync" - "time" - - "log/slog" - - "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/goridge/v4/pkg/relay" - "github.com/roadrunner-server/goridge/v4/pkg/socket" - "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/internal" - "github.com/roadrunner-server/pool/v2/worker" - "github.com/shirou/gopsutil/process" - - "golang.org/x/sync/errgroup" -) - -// Factory connects to external stack using socket server. -type Factory struct { - // listens for incoming connections from underlying processes - ls net.Listener - // sockets which are waiting for process association - relays sync.Map - log *slog.Logger -} - -// NewSocketServer returns Factory attached to a given socket listener. -func NewSocketServer(ls net.Listener, log *slog.Logger) *Factory { - f := &Factory{ - ls: ls, - log: log, - } - - // Be careful - // https://github.com/go101/go101/wiki/About-memory-ordering-guarantees-made-by-atomic-operations-in-Go - // https://github.com/golang/go/issues/5045 - go func() { - err := f.listen() - // there is no logger here, use fmt - if err != nil { - var opErr *net.OpError - if stderr.As(err, &opErr) { - if opErr.Err.Error() == "use of closed network connection" { - return - } - } - - log.Warn("socket server listen", "error", err) - } - }() - - return f -} - -// blocking operation, returns an error -func (f *Factory) listen() error { - errGr := &errgroup.Group{} - errGr.Go(func() error { - for { - conn, err := f.ls.Accept() - if err != nil { - return err - } - - rl := socket.NewSocketRelay(conn) - pid, err := internal.Pid(rl) - if err != nil { - return err - } - - f.attachRelayToPid(pid, rl) - } - }) - - return errGr.Wait() -} - -type socketSpawn struct { - w *worker.Process - err error -} - -// SpawnWorkerWithContext Creates a Process and connects it to the appropriate relay or return an error -func (f *Factory) SpawnWorkerWithContext(ctx context.Context, cmd *exec.Cmd, options ...worker.Options) (*worker.Process, error) { - c := make(chan socketSpawn) - go func() { - w, err := worker.InitBaseWorker(cmd, options...) - if err != nil { - select { - case c <- socketSpawn{ - w: nil, - err: err, - }: - return - default: - return - } - } - - err = w.Start() - if err != nil { - select { - case c <- socketSpawn{ - w: nil, - err: err, - }: - return - default: - return - } - } - - rl, err := f.findRelayWithContext(ctx, w) - if err != nil { - _ = w.Kill() - select { - // try to write a result - case c <- socketSpawn{ - w: nil, - err: err, - }: - return - // if no receivers - return - default: - return - } - } - - w.AttachRelay(rl) - w.State().Transition(fsm.StateReady) - - select { - case c <- socketSpawn{ - w: w, - err: nil, - }: - return - default: - _ = w.Kill() - return - } - }() - - select { - case <-ctx.Done(): - return nil, errors.E(errors.TimeOut) - case res := <-c: - if res.err != nil { - return nil, res.err - } - - return res.w, nil - } -} - -// Close socket factory and underlying socket connection. -func (f *Factory) Close() error { - return f.ls.Close() -} - -// waits for Process to connect over socket and returns associated relay or timeout -func (f *Factory) findRelayWithContext(ctx context.Context, w *worker.Process) (*socket.Relay, error) { - ticker := time.NewTicker(time.Millisecond * 10) - defer ticker.Stop() - for { - // fast path: check relay map immediately - rl, ok := f.relays.LoadAndDelete(w.Pid()) - if ok { - return rl.(*socket.Relay), nil - } - - select { - case <-ctx.Done(): - return nil, errors.E(errors.Op("findRelayWithContext"), errors.TimeOut) - case <-ticker.C: - // check if process still exists - _, err := process.NewProcess(int32(w.Pid())) //nolint:gosec - if err != nil { - return nil, err - } - } - } -} - -// chan to store relay associated with specific pid -func (f *Factory) attachRelayToPid(pid int64, relay relay.Relay) { - f.relays.Store(pid, relay) -} diff --git a/ipc/socket/socket_spawn_test.go b/ipc/socket/socket_spawn_test.go deleted file mode 100644 index c182ffc..0000000 --- a/ipc/socket/socket_spawn_test.go +++ /dev/null @@ -1,518 +0,0 @@ -package socket - -import ( - "context" - "net" - "os/exec" - "sync" - "testing" - "time" - - "log/slog" - - "github.com/roadrunner-server/pool/v2/payload" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var log = slog.New(slog.DiscardHandler) - -func Test_Tcp_Start2(t *testing.T) { - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.NoError(t, err) - assert.NotNil(t, w) - - go func() { - assert.NoError(t, w.Wait()) - }() - - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } -} - -func Test_Tcp_StartCloseFactory2(t *testing.T) { - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - f := NewSocketServer(ls, log) - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - w, err := f.SpawnWorkerWithContext(ctx, cmd) - assert.NoError(t, err) - assert.NotNil(t, w) - - go func() { - require.NoError(t, w.Wait()) - }() - - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } -} - -func Test_Tcp_StartError2(t *testing.T) { - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - err = cmd.Start() - if err != nil { - t.Errorf("error executing the command: error %v", err) - } - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Tcp_Failboot2(t *testing.T) { - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - err3 := ls.Close() - if err3 != nil { - t.Errorf("error closing the listener: error %v", err3) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/failboot.php") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - w, err2 := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Nil(t, w) - assert.Error(t, err2) -} - -func Test_Tcp_Invalid2(t *testing.T) { - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/invalid.php") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*15) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Tcp_Broken2(t *testing.T) { - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "broken", "tcp") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*15) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - wg := sync.WaitGroup{} - wg.Go(func() { - errW := w.Wait() - assert.Error(t, errW) - }) - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.Error(t, err) - assert.Nil(t, res) - wg.Wait() - - time.Sleep(time.Second) - err2 := w.Stop() - // write tcp 127.0.0.1:9007->127.0.0.1:34204: use of closed network connection - // but process exited - assert.NoError(t, err2) -} - -func Test_Tcp_Echo2(t *testing.T) { - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - - ctx, cancel := context.WithTimeout(t.Context(), time.Minute) - defer cancel() - - w, _ := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.NoError(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - assert.Equal(t, "hello", res.String()) -} - -func Test_Unix_Start2(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "unix") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(t.Context(), cmd) - assert.NoError(t, err) - assert.NotNil(t, w) - - go func() { - assert.NoError(t, w.Wait()) - }() - - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } -} - -func Test_Unix_Failboot2(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/failboot.php") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Nil(t, w) - assert.Error(t, err) -} - -func Test_Unix_Timeout2(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/slow-client.php", "echo", "unix", "200", "0") - - ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond*100) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - - assert.Nil(t, w) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Timeout") -} - -func Test_Unix_Invalid2(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/invalid.php") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*15) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Unix_Broken2(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "broken", "unix") - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*15) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - wg := &sync.WaitGroup{} - wg.Go(func() { - errW := w.Wait() - assert.Error(t, errW) - }) - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.Error(t, err) - assert.Nil(t, res) - wg.Wait() - - time.Sleep(time.Second) - err = w.Stop() - assert.NoError(t, err) -} - -func Test_Unix_Echo2(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - require.NoError(t, errC) - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "unix") - - ctx, cancel := context.WithTimeout(t.Context(), time.Minute) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.NoError(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - assert.Equal(t, "hello", res.String()) -} - -func Benchmark_Tcp_SpawnWorker_Stop2(b *testing.B) { - ls, err := net.Listen("unix", "sock.unix") - if assert.NoError(b, err) { - defer func() { - errC := ls.Close() - require.NoError(b, errC) - }() - } else { - b.Skip("socket is busy") - } - - f := NewSocketServer(ls, log) - for b.Loop() { - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - - w, err := f.SpawnWorkerWithContext(b.Context(), cmd) - if err != nil { - b.Fatal(err) - } - go func() { - assert.NoError(b, w.Wait()) - }() - - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - } -} - -func Benchmark_Tcp_Worker_ExecEcho2(b *testing.B) { - ls, err := net.Listen("unix", "sock.unix") - if assert.NoError(b, err) { - defer func() { - errC := ls.Close() - require.NoError(b, errC) - }() - } else { - b.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - - ctx, cancel := context.WithTimeout(b.Context(), time.Second*15) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - b.Fatal(err) - } - defer func() { - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} - -func Benchmark_Unix_SpawnWorker_Stop2(b *testing.B) { - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - errC := ls.Close() - require.NoError(b, errC) - }() - } else { - b.Skip("socket is busy") - } - - f := NewSocketServer(ls, log) - for b.Loop() { - cmd := exec.Command("php", "../../tests/client.php", "echo", "unix") - - w, err := f.SpawnWorkerWithContext(b.Context(), cmd) - if err != nil { - b.Fatal(err) - } - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - } -} - -func Benchmark_Unix_Worker_ExecEcho2(b *testing.B) { - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - errC := ls.Close() - require.NoError(b, errC) - }() - } else { - b.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "unix") - - ctx, cancel := context.WithTimeout(b.Context(), time.Second*15) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - b.Fatal(err) - } - defer func() { - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} diff --git a/ipc/socket/socket_test.go b/ipc/socket/socket_test.go deleted file mode 100755 index 241fe3a..0000000 --- a/ipc/socket/socket_test.go +++ /dev/null @@ -1,563 +0,0 @@ -package socket - -import ( - "context" - "net" - "os/exec" - "sync" - "testing" - "time" - - "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/pool/v2/payload" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_Tcp_Start(t *testing.T) { - ctx := t.Context() - time.Sleep(time.Millisecond * 10) // to ensure free socket - - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.NoError(t, err) - assert.NotNil(t, w) - - go func() { - assert.NoError(t, w.Wait()) - }() - - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } -} - -func Test_Tcp_StartCloseFactory(t *testing.T) { - time.Sleep(time.Millisecond * 10) // to ensure free socket - ctx := t.Context() - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - f := NewSocketServer(ls, log) - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - - w, err := f.SpawnWorkerWithContext(ctx, cmd) - assert.NoError(t, err) - assert.NotNil(t, w) - - go func() { - require.NoError(t, w.Wait()) - }() - - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } -} - -func Test_Tcp_StartError(t *testing.T) { - time.Sleep(time.Millisecond * 10) // to ensure free socket - ctx, cancel := context.WithTimeout(t.Context(), time.Second*15) - defer cancel() - - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "pipes") - err = cmd.Start() - if err != nil { - t.Errorf("error executing the command: error %v", err) - } - - serv := NewSocketServer(ls, log) - time.Sleep(time.Second * 2) - w, err := serv.SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Tcp_Failboot(t *testing.T) { - time.Sleep(time.Millisecond * 10) // to ensure free socket - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - err3 := ls.Close() - if err3 != nil { - t.Errorf("error closing the listener: error %v", err3) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/failboot.php") - - w, err2 := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Nil(t, w) - assert.Error(t, err2) -} - -func Test_Tcp_Timeout(t *testing.T) { - time.Sleep(time.Millisecond * 10) // to ensure free socket - ctx, cancel := context.WithTimeout(t.Context(), time.Microsecond) - defer cancel() - - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/slow-client.php", "echo", "tcp", "200", "0") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Nil(t, w) - assert.Error(t, err) - assert.True(t, errors.Is(errors.TimeOut, err)) -} - -func Test_Tcp_Invalid(t *testing.T) { - time.Sleep(time.Millisecond * 10) // to ensure free socket - ctx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/invalid.php") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Tcp_Broken(t *testing.T) { - time.Sleep(time.Millisecond * 10) // to ensure free socket - ctx := t.Context() - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - errC := ls.Close() - if errC != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "broken", "tcp") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - wg := sync.WaitGroup{} - wg.Go(func() { - errW := w.Wait() - assert.Error(t, errW) - }) - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - assert.Error(t, err) - assert.Nil(t, res) - wg.Wait() - - time.Sleep(time.Second) - err2 := w.Stop() - // write tcp 127.0.0.1:9007->127.0.0.1:34204: use of closed network connection - // but process is stopped - assert.NoError(t, err2) -} - -func Test_Tcp_Echo(t *testing.T) { - time.Sleep(time.Millisecond * 10) // to ensure free socket - ctx := t.Context() - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if assert.NoError(t, err) { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - - w, _ := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.NoError(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - assert.Equal(t, "hello", res.String()) -} - -func Test_Unix_Start(t *testing.T) { - ctx := t.Context() - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "unix") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.NoError(t, err) - assert.NotNil(t, w) - - go func() { - assert.NoError(t, w.Wait()) - }() - - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } -} - -func Test_Unix_Failboot(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - ctx, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - - cmd := exec.Command("php", "../../tests/failboot.php") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Nil(t, w) - assert.Error(t, err) -} - -func Test_Unix_Timeout(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/slow-client.php", "echo", "unix", "200", "0") - ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond*100) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Nil(t, w) - assert.Error(t, err) - assert.True(t, errors.Is(errors.TimeOut, err)) -} - -func Test_Unix_Invalid(t *testing.T) { - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/invalid.php") - ctx, cancel := context.WithTimeout(t.Context(), time.Second*10) - defer cancel() - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - assert.Error(t, err) - assert.Nil(t, w) -} - -func Test_Unix_Broken(t *testing.T) { - ctx := t.Context() - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - errC := ls.Close() - if errC != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "broken", "unix") - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - wg := &sync.WaitGroup{} - wg.Go(func() { - errW := w.Wait() - assert.Error(t, errW) - }) - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.Error(t, err) - assert.Nil(t, res) - - time.Sleep(time.Second) - err = w.Stop() - assert.NoError(t, err) - - wg.Wait() -} - -func Test_Unix_Echo(t *testing.T) { - ctx := t.Context() - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - t.Errorf("error closing the listener: error %v", err) - } - }() - } else { - t.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "unix") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - t.Fatal(err) - } - go func() { - assert.NoError(t, w.Wait()) - }() - defer func() { - err = w.Stop() - if err != nil { - t.Errorf("error stopping the Process: error %v", err) - } - }() - - res, err := w.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}) - - assert.NoError(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body) - assert.Empty(t, res.Context) - - assert.Equal(t, "hello", res.String()) -} - -func Benchmark_Tcp_SpawnWorker_Stop(b *testing.B) { - ctx := b.Context() - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - b.Errorf("error closing the listener: error %v", err) - } - }() - } else { - b.Skip("socket is busy") - } - - f := NewSocketServer(ls, log) - for b.Loop() { - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - - w, err := f.SpawnWorkerWithContext(ctx, cmd) - if err != nil { - b.Fatal(err) - } - go func() { - assert.NoError(b, w.Wait()) - }() - - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - } -} - -func Benchmark_Tcp_Worker_ExecEcho(b *testing.B) { - ctx := b.Context() - ls, err := net.Listen("tcp", "127.0.0.1:9007") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - b.Errorf("error closing the listener: error %v", err) - } - }() - } else { - b.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "tcp") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - b.Fatal(err) - } - defer func() { - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} - -func Benchmark_Unix_SpawnWorker_Stop(b *testing.B) { - ctx := b.Context() - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - b.Errorf("error closing the listener: error %v", err) - } - }() - } else { - b.Skip("socket is busy") - } - - f := NewSocketServer(ls, log) - for b.Loop() { - cmd := exec.Command("php", "../../tests/client.php", "echo", "unix") - - w, err := f.SpawnWorkerWithContext(ctx, cmd) - if err != nil { - b.Fatal(err) - } - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - } -} - -func Benchmark_Unix_Worker_ExecEcho(b *testing.B) { - ctx := b.Context() - ls, err := net.Listen("unix", "sock.unix") - if err == nil { - defer func() { - err = ls.Close() - if err != nil { - b.Errorf("error closing the listener: error %v", err) - } - }() - } else { - b.Skip("socket is busy") - } - - cmd := exec.Command("php", "../../tests/client.php", "echo", "unix") - - w, err := NewSocketServer(ls, log).SpawnWorkerWithContext(ctx, cmd) - if err != nil { - b.Fatal(err) - } - defer func() { - err = w.Stop() - if err != nil { - b.Errorf("error stopping the Process: error %v", err) - } - }() - - for b.Loop() { - if _, err := w.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}); err != nil { - b.Fail() - } - } -} diff --git a/payload/payload.go b/payload/payload.go deleted file mode 100644 index 4953e21..0000000 --- a/payload/payload.go +++ /dev/null @@ -1,27 +0,0 @@ -package payload - -import ( - "unsafe" -) - -// Payload carries binary header and body to stack and -// back to the server. -type Payload struct { - // Context represent payload context, might be omitted. - Context []byte - // body contains binary payload to be processed by WorkerProcess. - Body []byte - // Type of codec used to decode/encode payload. - Codec byte - // Flags - Flags byte -} - -// String returns payload body as string -func (p *Payload) String() string { - if len(p.Body) == 0 { - return "" - } - - return unsafe.String(unsafe.SliceData(p.Body), len(p.Body)) -} diff --git a/pool/allocator.go b/pool/allocator.go index e5f8229..9a36ff2 100644 --- a/pool/allocator.go +++ b/pool/allocator.go @@ -2,43 +2,37 @@ package pool import ( "context" - "os/exec" - "time" - "log/slog" "github.com/roadrunner-server/errors" "github.com/roadrunner-server/events" + "github.com/roadrunner-server/pool/v2/fsm" "github.com/roadrunner-server/pool/v2/worker" "golang.org/x/sync/errgroup" ) -// Factory is responsible for wrapping given command into tasks WorkerProcess. -type Factory interface { - // SpawnWorkerWithContext creates a new WorkerProcess process based on given command with context. - // Process must not be started. - SpawnWorkerWithContext(context.Context, *exec.Cmd, ...worker.Options) (*worker.Process, error) - // Close the factory and underlying connections. - Close() error -} - -// NewPoolAllocator initializes allocator of the workers -func NewPoolAllocator(ctx context.Context, timeout time.Duration, maxExecs uint64, factory Factory, cmd Command, command []string, log *slog.Logger) func() (*worker.Process, error) { +// NewPoolAllocator initializes allocator of the workers: plain process spawn, no IPC handshake. +func NewPoolAllocator(ctx context.Context, cmd Command, command []string, log *slog.Logger) func() (*worker.Process, error) { return func() (*worker.Process, error) { - ctxT, cancel := context.WithTimeout(ctx, timeout) - defer cancel() + // pool is being shut down / boot context canceled + if err := ctx.Err(); err != nil { + return nil, errors.E(errors.TimeOut, err) + } - w, err := factory.SpawnWorkerWithContext(ctxT, cmd(command), worker.WithLog(log), worker.WithMaxExecs(maxExecs)) + w, err := worker.InitBaseWorker(cmd(command), worker.WithLog(log)) if err != nil { - // context deadline - if errors.Is(errors.TimeOut, err) { - return nil, errors.Str("failed to spawn a worker, possible reasons: https://docs.roadrunner.dev/error-codes/allocate-timeout") - } return nil, err } - // wrap sync worker - log.Debug("worker is allocated", "pid", w.Pid(), "max_execs", w.MaxExecs(), "internal_event_name", events.EventWorkerConstruct.String()) + err = w.Start() + if err != nil { + return nil, err + } + + // readiness == process started; protocol-level readiness is plugin-side (the worker dials in) + w.State().Transition(fsm.StateReady) + + log.Debug("worker is allocated", "pid", w.Pid(), "internal_event_name", events.EventWorkerConstruct.String()) return w, nil } } diff --git a/pool/config.go b/pool/config.go index bdcb536..2cc9e68 100644 --- a/pool/config.go +++ b/pool/config.go @@ -9,19 +9,11 @@ const maxWorkers = 2048 // Config .. Pool config Configures the pool behavior. type Config struct { - // Debug flag creates new fresh worker before every request. - Debug bool // Command used to override the server command with the custom one Command []string `mapstructure:"command"` - // MaxQueueSize is maximum allowed queue size with the pending requests to the workers poll - MaxQueueSize uint64 `mapstructure:"max_queue_size"` // NumWorkers defines how many sub-processes can be run at once. This value // might be doubled by Swapper while hot-swap. Defaults to number of CPU cores. NumWorkers uint64 `mapstructure:"num_workers"` - // MaxJobs defines how many executions is allowed for the worker until - // its destruction. set 1 to create new process for each new task, 0 to let - // worker handle as many tasks as it can. - MaxJobs uint64 `mapstructure:"max_jobs"` // AllocateTimeout defines for how long pool will be waiting for a worker to // be freed to handle the task. Defaults to 60s. AllocateTimeout time.Duration `mapstructure:"allocate_timeout"` @@ -30,8 +22,6 @@ type Config struct { DestroyTimeout time.Duration `mapstructure:"destroy_timeout"` // ResetTimeout defines how long pool should wait before start killing workers ResetTimeout time.Duration `mapstructure:"reset_timeout"` - // Stream read operation timeout - StreamTimeout time.Duration `mapstructure:"stream_timeout"` // Supervision config to limit worker and pool memory usage. Supervisor *SupervisorConfig `mapstructure:"supervisor"` // Dynamic allocation config @@ -48,10 +38,6 @@ func (cfg *Config) InitDefaults() { cfg.AllocateTimeout = time.Minute } - if cfg.StreamTimeout == 0 { - cfg.StreamTimeout = time.Minute - } - if cfg.DestroyTimeout == 0 { cfg.DestroyTimeout = time.Minute } @@ -77,8 +63,6 @@ type SupervisorConfig struct { TTL time.Duration `mapstructure:"ttl"` // IdleTTL defines the maximum duration worker can spend in idle mode. Disabled when 0. IdleTTL time.Duration `mapstructure:"idle_ttl"` - // ExecTTL defines maximum lifetime per job. - ExecTTL time.Duration `mapstructure:"exec_ttl"` // MaxWorkerMemory limits memory per worker. MaxWorkerMemory uint64 `mapstructure:"max_worker_memory"` } diff --git a/pool/static_pool/debug.go b/pool/static_pool/debug.go deleted file mode 100644 index 96149f9..0000000 --- a/pool/static_pool/debug.go +++ /dev/null @@ -1,145 +0,0 @@ -package static_pool - -import ( - "context" - "runtime" - - "github.com/roadrunner-server/events" - "github.com/roadrunner-server/goridge/v4/pkg/frame" - "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/payload" -) - -// execDebug used when debug mode was not set and exec_ttl is 0 -func (sp *Pool) execDebug(ctx context.Context, p *payload.Payload, stopCh chan struct{}) (chan *PExec, error) { - sp.log.Debug("executing in debug mode, worker will be destroyed after response is received") - w, err := sp.allocator() - if err != nil { - return nil, err - } - - go func() { - // read the exit status to prevent process to become a zombie - _ = w.Wait() - }() - - rsp, err := w.Exec(ctx, p) - if err != nil { - return nil, err - } - - switch { - case rsp.Flags&frame.STREAM != 0: - // create a channel for the stream (only if there are no errors) - resp := make(chan *PExec, 5) - // send the initial frame - resp <- newPExec(rsp, nil) - - // in case of stream, we should not return worker immediately - go func() { //nolint:gosec // G118 - intentional: per-iteration exec timeout must be independent of request context - // would be called on Goexit - defer func() { - sp.log.Debug("stopping [stream] worker", "pid", w.Pid(), "state", w.State().String()) - close(resp) - // destroy the worker - errD := w.Stop() - if errD != nil { - sp.log.Debug( - "debug mode: worker stopped with error", - "reason", "worker error", - "pid", w.Pid(), - "internal_event_name", events.EventWorkerError.String(), - "error", errD, - ) - } - }() - - // stream iterator - for { - select { - // we received stop signal - case <-stopCh: - sp.log.Debug("stream stop signal received", "pid", w.Pid(), "state", w.State().String()) - ctxT, cancelT := context.WithTimeout(ctx, sp.cfg.StreamTimeout) - err = w.StreamCancel(ctxT) - cancelT() - if err != nil { - w.State().Transition(fsm.StateErrored) - sp.log.Warn("stream cancel error", "error", err) - } else { - // successfully canceled - w.State().Transition(fsm.StateReady) - sp.log.Debug("transition to the ready state", "from", w.State().String()) - } - - runtime.Goexit() - default: - // we have to set a stream timeout on every request - switch sp.supervisedExec { - case true: - ctxT, cancelT := context.WithTimeout(context.Background(), sp.cfg.Supervisor.ExecTTL) - pld, next, errI := w.StreamIterWithContext(ctxT) - cancelT() - if errI != nil { - sp.log.Warn("stream error", "error", errI) - - resp <- newPExec(nil, errI) - - // move worker to the invalid state to restart - w.State().Transition(fsm.StateInvalid) - runtime.Goexit() - } - - resp <- newPExec(pld, nil) - - if !next { - w.State().Transition(fsm.StateReady) - // we've got the last frame - runtime.Goexit() - } - case false: - // non supervised execution, can potentially hang here - pld, next, errI := w.StreamIter() - if errI != nil { - sp.log.Warn("stream iter error", "error", errI) - // send error response - resp <- newPExec(nil, errI) - - // move worker to the invalid state to restart - w.State().Transition(fsm.StateInvalid) - runtime.Goexit() - } - - resp <- newPExec(pld, nil) - - if !next { - w.State().Transition(fsm.StateReady) - // we've got the last frame - runtime.Goexit() - } - } - } - } - }() - - return resp, nil - default: - resp := make(chan *PExec, 1) - resp <- newPExec(rsp, nil) - // close the channel - close(resp) - - errD := w.Stop() - if errD != nil { - sp.log.Debug( - "debug mode: worker stopped with error", - "reason", "worker error", - "pid", w.Pid(), - "internal_event_name", events.EventWorkerError.String(), - "error", errD, - ) - } - - return resp, nil - } -} diff --git a/pool/static_pool/debug_test.go b/pool/static_pool/debug_test.go deleted file mode 100644 index 526f5c1..0000000 --- a/pool/static_pool/debug_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package static_pool - -import ( - "os/exec" - "testing" - "time" - - "log/slog" - - "github.com/roadrunner-server/pool/v2/ipc/pipe" - "github.com/roadrunner-server/pool/v2/payload" - "github.com/roadrunner-server/pool/v2/pool" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestExecDebug_NonStream(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: true, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 5, - }, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - // Debug mode should have zero pre-allocated workers - assert.Empty(t, p.Workers()) - - // Execute request — goes through execDebug (non-stream branch) - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: []byte("")}, make(chan struct{})) - require.NoError(t, err) - - resp := <-r - assert.Equal(t, []byte("hello"), resp.Body()) - assert.NoError(t, resp.Error()) - - // Worker should be destroyed after response — no workers in pool - assert.Empty(t, p.Workers()) -} - -func TestExecDebug_FreshWorkerPerRequest(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "pid", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: true, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 5, - }, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - // Each request in debug mode should use a fresh worker (different PID) - pids := make(map[string]struct{}) - for range 3 { - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - require.NoError(t, err) - resp := <-r - pids[string(resp.Body())] = struct{}{} - } - - // All PIDs should be different — each request creates a new worker - assert.Len(t, pids, 3, "debug mode should spawn a fresh worker for each request") -} diff --git a/pool/static_pool/dyn_allocator.go b/pool/static_pool/dyn_allocator.go index ab681fa..88ce655 100644 --- a/pool/static_pool/dyn_allocator.go +++ b/pool/static_pool/dyn_allocator.go @@ -97,8 +97,6 @@ func (da *dynAllocator) addMoreWorkers() { return } - // we're starting from the 1 because we already allocated one worker which would be released in the Exec function - // i < da.spawnRate - we can't allocate more workers than the spawn rate for range da.spawnRate { // spawn as many workers as the user specified in the spawn rate configuration, but not more than max workers if da.currAllocated.Load() >= da.maxWorkers { diff --git a/pool/static_pool/dyn_allocator_test.go b/pool/static_pool/dyn_allocator_test.go index 3398928..a5652fa 100644 --- a/pool/static_pool/dyn_allocator_test.go +++ b/pool/static_pool/dyn_allocator_test.go @@ -1,607 +1,75 @@ package static_pool import ( - "os/exec" - "sync" + "context" + "log/slog" "testing" "time" - "log/slog" - - "github.com/roadrunner-server/pool/v2/ipc/pipe" - "github.com/roadrunner-server/pool/v2/payload" "github.com/roadrunner-server/pool/v2/pool" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var testDynCfg = &pool.Config{ - NumWorkers: 5, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 10, - DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ - MaxWorkers: 25, - SpawnRate: 5, - IdleTimeout: time.Second * 10, - }, -} - -func Test_DynAllocator(t *testing.T) { - np, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testDynCfg, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, np) - - r, err := np.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, err) - resp := <-r - - assert.Equal(t, []byte("hello"), resp.Body()) - assert.NoError(t, err) - t.Cleanup(func() { np.Destroy(t.Context()) }) -} - -func Test_DynAllocatorManyReq(t *testing.T) { - var testDynCfgMany = &pool.Config{ - NumWorkers: 5, - MaxJobs: 2, - AllocateTimeout: time.Second * 1, - DestroyTimeout: time.Second * 10, - ResetTimeout: time.Second * 5, - DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ - MaxWorkers: 25, - SpawnRate: 5, - IdleTimeout: time.Second * 10, - }, - } - - np, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/client.php", "slow_req", "pipes") - }, - pipe.NewPipeFactory(slog.Default()), - testDynCfgMany, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, np) - - wg := &sync.WaitGroup{} - go func() { - for range 1000 { - wg.Go(func() { - r, erre := np.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - if erre != nil { - t.Log("failed request: ", erre.Error()) - return - } - resp := <-r - assert.Equal(t, []byte("hello"), resp.Body()) - }) - } - }() - - go func() { - for range 10 { - time.Sleep(time.Second) - _ = np.Reset(t.Context()) - } - }() - - wg.Wait() - - time.Sleep(time.Second * 30) - - assert.Equal(t, 5, len(np.Workers())) - t.Cleanup(func() { np.Destroy(t.Context()) }) -} - -func Test_DynamicPool_OverMax(t *testing.T) { - dynAllCfg := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 20, - DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ - MaxWorkers: 1, - IdleTimeout: time.Second * 15, - SpawnRate: 10, - }, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/worker-slow-dyn.php") - }, - pipe.NewPipeFactory(slog.Default()), - dynAllCfg, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - wg := &sync.WaitGroup{} - - wg.Go(func() { - t.Log("sending request 1") - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, err) - select { - case resp := <-r: - assert.Equal(t, []byte("hello world"), resp.Body()) - assert.NoError(t, err) - t.Log("request 1 finished") - case <-time.After(time.Second * 10): - assert.Fail(t, "timeout") - } - }) - wg.Go(func() { - // sleep to ensure the first request is being processed first - // this request should trigger dynamic allocation attempt and return an error - time.Sleep(time.Second) - t.Log("sending request 2") - _, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.Error(t, err) - }) - - t.Log("waiting for the requests 1 and 2") - wg.Wait() - t.Log("wait 1 and 2 finished") - - // request 3 and 4 should be processed normally, since we have 2 workers now (1 initial + 1 dynamic) - t.Log("starting requests 3 and 4") - require.Len(t, p.Workers(), 2) - - wg.Go(func() { - t.Log("request 3") - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, err) - select { - case resp := <-r: - t.Log("request 3 finished") - assert.Equal(t, []byte("hello world"), resp.Body()) - assert.NoError(t, err) - case <-time.After(time.Second * 10): - assert.Fail(t, "timeout") - } - }) - wg.Go(func() { - t.Log("request 4") - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, err) - select { - case resp := <-r: - t.Log("request 4 finished") - assert.Equal(t, []byte("hello world"), resp.Body()) - assert.NoError(t, err) - case <-time.After(time.Second * 10): - assert.Fail(t, "timeout") - } - }) - - t.Log("waiting for the requests 3 and 4") - wg.Wait() - time.Sleep(time.Second * 20) - assert.Len(t, p.Workers(), 1) - t.Cleanup(func() { - p.Destroy(t.Context()) - }) -} - -func Test_DynamicPool(t *testing.T) { - dynAllCfg := &pool.Config{ +// white-box test: addMoreWorkers has no in-pool trigger anymore (it used to be fired +// by the Exec NoFreeWorkers path), the scale-up signal is expected to come from plugins +func Test_DynAllocator_ScaleUpAndIdleDealloc(t *testing.T) { + cfg := &pool.Config{ NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second, + AllocateTimeout: time.Second * 10, + DestroyTimeout: time.Second * 10, DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ MaxWorkers: 5, - IdleTimeout: time.Second * 15, SpawnRate: 2, - }, - } - - p, errp := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/worker-slow-dyn.php") - }, - pipe.NewPipeFactory(slog.Default()), - dynAllCfg, - slog.Default(), - ) - assert.NoError(t, errp) - assert.NotNil(t, p) - - wg := &sync.WaitGroup{} - wg.Go(func() { - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, err) - select { - case resp := <-r: - assert.Equal(t, []byte("hello world"), resp.Body()) - assert.NoError(t, err) - case <-time.After(time.Second * 10): - assert.Fail(t, "timeout") - } - }) - - wg.Go(func() { - time.Sleep(time.Second) - _, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.Error(t, err) - }) - - wg.Wait() - - time.Sleep(time.Second * 20) - require.Len(t, p.Workers(), 1) - t.Cleanup(func() { - p.Destroy(t.Context()) - }) -} - -func Test_DynamicPool_500W(t *testing.T) { - dynAllCfg := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second, - DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ - MaxWorkers: 10, - IdleTimeout: time.Second * 15, - // should be corrected to 10 by RR - SpawnRate: 11, - }, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/worker-slow-dyn.php") - }, - pipe.NewPipeFactory(slog.Default()), - dynAllCfg, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - require.Len(t, p.Workers(), 1) - - wg := &sync.WaitGroup{} - - wg.Go(func() { - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - select { - case resp := <-r: - assert.Equal(t, []byte("hello world"), resp.Body()) - assert.NoError(t, err) - case <-time.After(time.Second * 10): - assert.Fail(t, "timeout") - } - }) - - wg.Go(func() { - time.Sleep(time.Second * 1) - _, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.Error(t, err) - }) - - wg.Wait() - - time.Sleep(time.Second * 30) - - require.Len(t, p.Workers(), 1) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} - -// Test_DynAllocator_100Workers verifies that the dynamic allocator can scale up to 100 dynamic workers -// in batches of 20 (spawnRate) and then properly deallocate them all after the idle timeout. -func Test_DynAllocator_100Workers(t *testing.T) { - cfg := &pool.Config{ - NumWorkers: 5, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second * 30, - DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ - MaxWorkers: 100, - SpawnRate: 20, - IdleTimeout: time.Second * 3, - }, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/client.php", "delay", "pipes") - }, - pipe.NewPipeFactory(slog.Default()), - cfg, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - require.Len(t, p.Workers(), 5) - - wg := &sync.WaitGroup{} - // Fire requests in waves to sustain pressure across rate limiter windows. - // Each wave saturates current workers; failed requests trigger addMoreWorkers (20 per call, rate-limited to 1/sec). - // Over 8 waves ≈ 8 seconds: up to 5 batches * 20 = 100 dynamic workers. - for wave := range 8 { - for range 60 { - wg.Go(func() { - // 5-second delay keeps workers busy long enough to create sustained pressure - r, erre := p.Exec(t.Context(), &payload.Payload{Body: []byte("5000"), Context: nil}, make(chan struct{})) - if erre != nil { - return - } - <-r - }) - } - if wave < 7 { - time.Sleep(time.Second) - } - } - - wg.Wait() - - totalAfterLoad := len(p.Workers()) - dynAfterLoad := p.NumDynamic() - t.Log("workers after load:", totalAfterLoad, "dynamic:", dynAfterLoad) - assert.Greater(t, dynAfterLoad, uint64(0), "dynamic allocation should have occurred") - - // Wait for idle timeout (3s) + deallocation cycles. - // With 100 dynamic workers and SpawnRate=20: 5 deallocation batches, each triggered at IdleTimeout interval. - // 5 batches * 3s = 15s + extra buffer. - time.Sleep(time.Second * 30) - - assert.Equal(t, uint64(0), p.NumDynamic(), "all dynamic workers should be deallocated") - assert.Len(t, p.Workers(), 5, "should return to base worker count") -} - -// Test_DynAllocator_ReallocationCycle verifies that after all dynamic workers are deallocated -// (idle TTL listener exits, started=false), a new pressure spike correctly restarts the listener -// and allocates workers again. This tests the full listener lifecycle: start → deallocate → stop → restart. -func Test_DynAllocator_ReallocationCycle(t *testing.T) { - cfg := &pool.Config{ - NumWorkers: 2, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second * 20, - DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ - MaxWorkers: 10, - SpawnRate: 5, IdleTimeout: time.Second * 2, }, } - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/client.php", "delay", "pipes") - }, - pipe.NewPipeFactory(slog.Default()), - cfg, - slog.Default(), - ) + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - require.Len(t, p.Workers(), 2) - - // === Cycle 1: Trigger allocation, then wait for full deallocation === - wg := &sync.WaitGroup{} - for range 30 { - wg.Go(func() { - r, erre := p.Exec(t.Context(), &payload.Payload{Body: []byte("3000"), Context: nil}, make(chan struct{})) - if erre != nil { - return - } - <-r - }) - } - wg.Wait() - - dyn1 := p.NumDynamic() - t.Log("cycle 1 - dynamic workers allocated:", dyn1) - assert.Greater(t, dyn1, uint64(0), "cycle 1: dynamic allocation should have occurred") + require.NotNil(t, p.dynamicAllocator) - // Wait for full deallocation: idle timeout (2s) + deallocation batches (2 batches * 2s = 4s) + buffer - time.Sleep(time.Second * 10) + p.dynamicAllocator.addMoreWorkers() + assert.Equal(t, uint64(2), p.NumDynamic()) + assert.Len(t, p.Workers(), 3) - assert.Equal(t, uint64(0), p.NumDynamic(), "cycle 1: all dynamic workers should be deallocated") - assert.Len(t, p.Workers(), 2, "cycle 1: should return to base count") + // an immediate second call is rate-limited (1s cooldown) and must not allocate + p.dynamicAllocator.addMoreWorkers() + assert.Equal(t, uint64(2), p.NumDynamic()) - // === Cycle 2: Re-trigger allocation (listener must restart from started=false) === - for range 30 { - wg.Go(func() { - r, erre := p.Exec(t.Context(), &payload.Payload{Body: []byte("3000"), Context: nil}, make(chan struct{})) - if erre != nil { - return - } - <-r - }) - } - wg.Wait() - - dyn2 := p.NumDynamic() - t.Log("cycle 2 - dynamic workers allocated:", dyn2) - assert.Greater(t, dyn2, uint64(0), "cycle 2: dynamic allocation should have occurred (listener restarted)") + // after the idle window, the dynamically allocated workers are deallocated + require.Eventually(t, func() bool { + return p.NumDynamic() == 0 && len(p.Workers()) == 1 + }, 15*time.Second, 100*time.Millisecond) - // Wait for full deallocation again - time.Sleep(time.Second * 10) - - assert.Equal(t, uint64(0), p.NumDynamic(), "cycle 2: all dynamic workers should be deallocated") - assert.Len(t, p.Workers(), 2, "cycle 2: should return to base count after re-allocation") + p.Destroy(context.Background()) } -// ==================== Dynamic Allocator Edge Cases ==================== - -// Test_DynAllocator_SpawnRate_CappedByMaxWorkers verifies that spawnRate doesn't exceed maxWorkers. -// The `currAllocated >= maxWorkers` break in addMoreWorkers() spawn loop. Without it, over-allocation occurs. -func Test_DynAllocator_SpawnRate_CappedByMaxWorkers(t *testing.T) { +func Test_DynAllocator_MaxWorkersCap(t *testing.T) { cfg := &pool.Config{ NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 20, + AllocateTimeout: time.Second * 10, + DestroyTimeout: time.Second * 10, DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ MaxWorkers: 3, - SpawnRate: 10, // higher than maxWorkers - IdleTimeout: time.Second * 10, - }, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/worker-slow-dyn.php") - }, - pipe.NewPipeFactory(slog.Default()), - cfg, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - wg := &sync.WaitGroup{} - // Fire enough requests to trigger dynamic allocation - for range 20 { - wg.Go(func() { - r, erre := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - if erre != nil { - return - } - <-r - }) - } - - // Wait a bit for allocation to happen - time.Sleep(time.Second * 3) - - // Dynamic workers should not exceed maxWorkers=3 - dynCount := p.NumDynamic() - assert.LessOrEqual(t, dynCount, uint64(3), - "dynamic workers should not exceed maxWorkers, got %d", dynCount) - - wg.Wait() -} - -// Test_DynAllocator_CounterConsistency_AfterFailedRemoval verifies that currAllocated -// doesn't decrement when RemoveWorker fails (all workers busy). -// The break on removal failure in startIdleTTLListener() deallocation loop prevents counter desync. -func Test_DynAllocator_CounterConsistency_AfterFailedRemoval(t *testing.T) { - cfg := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 30, - DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ - MaxWorkers: 5, - SpawnRate: 5, - IdleTimeout: time.Second * 3, - }, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/client.php", "delay", "pipes") - }, - pipe.NewPipeFactory(slog.Default()), - cfg, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - wg := &sync.WaitGroup{} - // Fire requests to trigger dynamic allocation and keep workers busy - for range 20 { - wg.Go(func() { - // 3s delay keeps workers busy during deallocation attempt - r, erre := p.Exec(t.Context(), &payload.Payload{Body: []byte("3000")}, make(chan struct{})) - if erre != nil { - return - } - <-r - }) - } - - time.Sleep(time.Second * 2) - - // Verify dynamic workers were allocated - dynBefore := p.NumDynamic() - t.Log("dynamic workers before deallocation:", dynBefore) - - wg.Wait() - - // After all requests complete, wait for deallocation - time.Sleep(time.Second * 10) - - // Counter should eventually reach 0 (all dynamic workers deallocated) - assert.Equal(t, uint64(0), p.NumDynamic(), - "all dynamic workers should be deallocated after idle timeout") -} - -// Test_DynAllocator_RateLimit_ThunderingHerd verifies that the rate limiter prevents -// over-allocation under thundering herd conditions. -// The TryAcquire() rate limiter at the top of addMoreWorkers() gates concurrent allocation. Without it, each pending request -// could trigger a spawn batch. -func Test_DynAllocator_RateLimit_ThunderingHerd(t *testing.T) { - cfg := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 30, - DynamicAllocatorOpts: &pool.DynamicAllocationOpts{ - MaxWorkers: 5, - SpawnRate: 5, - IdleTimeout: time.Second * 10, + SpawnRate: 2, + IdleTimeout: time.Second * 30, }, } - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/worker-slow-dyn.php") - }, - pipe.NewPipeFactory(slog.Default()), - cfg, - slog.Default(), - ) + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - wg := &sync.WaitGroup{} - // Fire 50 simultaneous requests — thundering herd - for range 50 { - wg.Go(func() { - r, erre := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - if erre != nil { - return - } - <-r - }) - } - // Wait for allocation attempts - time.Sleep(time.Second * 3) + p.dynamicAllocator.addMoreWorkers() + assert.Equal(t, uint64(2), p.NumDynamic()) - // Dynamic workers should not exceed maxWorkers despite 50 concurrent requests - totalWorkers := len(p.Workers()) - dynWorkers := p.NumDynamic() + // wait out the rate-limiter cooldown; the next batch is capped at max_workers + require.Eventually(t, func() bool { + p.dynamicAllocator.addMoreWorkers() + return p.NumDynamic() == 3 + }, 10*time.Second, 200*time.Millisecond) - t.Log("total workers:", totalWorkers, "dynamic:", dynWorkers) - // 1 base + up to 5 dynamic = max 6 total - assert.LessOrEqual(t, totalWorkers, 6, - "total workers should not exceed base + maxDynamic, got %d", totalWorkers) - assert.LessOrEqual(t, dynWorkers, uint64(5), - "dynamic workers should not exceed maxWorkers=5, got %d", dynWorkers) + assert.Len(t, p.Workers(), 4) - wg.Wait() + p.Destroy(context.Background()) } diff --git a/pool/static_pool/fuzz_test.go b/pool/static_pool/fuzz_test.go deleted file mode 100644 index 2160ecf..0000000 --- a/pool/static_pool/fuzz_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package static_pool - -import ( - "log/slog" - "os/exec" - "testing" - - "github.com/roadrunner-server/pool/v2/ipc/pipe" - "github.com/roadrunner-server/pool/v2/payload" - "github.com/stretchr/testify/assert" -) - -func FuzzStaticPoolEcho(f *testing.F) { - f.Add([]byte("hello")) - - p, err := NewPool( - f.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.NoError(f, err) - assert.NotNil(f, p) - - sc := make(chan struct{}) - f.Fuzz(func(t *testing.T, data []byte) { - // data can't be empty - if len(data) == 0 { - data = []byte("1") - } - - respCh, err := p.Exec(t.Context(), &payload.Payload{Body: data}, sc) - assert.NoError(t, err) - res := <-respCh - assert.NotNil(t, res) - assert.NotNil(t, res.Body()) - assert.Empty(t, res.Context()) - - assert.Equal(t, data, res.Body()) - }) - f.Cleanup(func() { p.Destroy(f.Context()) }) -} diff --git a/pool/static_pool/options.go b/pool/static_pool/options.go index 1dd51b9..95de9fe 100644 --- a/pool/static_pool/options.go +++ b/pool/static_pool/options.go @@ -12,12 +12,6 @@ func WithLogger(logger *slog.Logger) Options { } } -func WithQueueSize(l uint64) Options { - return func(p *Pool) { - p.maxQueueSize.Store(l) - } -} - func WithNumWorkers(l uint64) Options { return func(p *Pool) { p.cfg.NumWorkers = l diff --git a/pool/static_pool/options_test.go b/pool/static_pool/options_test.go index 997f189..4ba73d8 100644 --- a/pool/static_pool/options_test.go +++ b/pool/static_pool/options_test.go @@ -15,19 +15,6 @@ func TestWithLogger_SetsLogger(t *testing.T) { assert.Equal(t, logger, p.log) } -func TestWithQueueSize_SetsMaxQueueSize(t *testing.T) { - p := &Pool{cfg: &pool.Config{}} - WithQueueSize(42)(p) - assert.Equal(t, uint64(42), p.maxQueueSize.Load()) -} - -func TestWithQueueSize_Zero(t *testing.T) { - p := &Pool{cfg: &pool.Config{}} - WithQueueSize(100)(p) - WithQueueSize(0)(p) - assert.Equal(t, uint64(0), p.maxQueueSize.Load()) -} - func TestWithNumWorkers_SetsNumWorkers(t *testing.T) { cfg := &pool.Config{NumWorkers: 1} p := &Pool{cfg: cfg} diff --git a/pool/static_pool/pool.go b/pool/static_pool/pool.go index 56a00d8..2374d7a 100644 --- a/pool/static_pool/pool.go +++ b/pool/static_pool/pool.go @@ -2,29 +2,18 @@ package static_pool import ( "context" - "runtime" - "sync" - "sync/atomic" - "unsafe" - "log/slog" + "sync" "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/events" - "github.com/roadrunner-server/goridge/v4/pkg/frame" - "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/payload" "github.com/roadrunner-server/pool/v2/pool" "github.com/roadrunner-server/pool/v2/worker" workerWatcher "github.com/roadrunner-server/pool/v2/worker_watcher" ) -const ( - // StopRequest can be sent by a worker to indicate that restart is required. - StopRequest = `{"stop":true}` -) - -// Pool controls worker creation, destruction and task routing. Pool uses a fixed number of workers. +// Pool controls worker creation, destruction and supervision. Pool uses a fixed number of workers. +// The pool is a lifecycle manager only: request delivery to the workers is handled +// by the plugins (over connectrpc), not by the pool. type Pool struct { // pool configuration cfg *pool.Config @@ -32,29 +21,19 @@ type Pool struct { log *slog.Logger // worker command creator cmd pool.Command - // creates and connects to workers - factory pool.Factory // manages worker states and TTLs ww *workerWatcher.WorkerWatcher // dynamic allocator dynamicAllocator *dynAllocator // allocate new worker allocator func() (*worker.Process, error) - // exec queue size - queue atomic.Uint64 - maxQueueSize atomic.Uint64 - // used in the supervised mode - supervisedExec bool - stopCh chan struct{} - mu sync.RWMutex + stopCh chan struct{} + mu sync.RWMutex } -// NewPool creates a new worker pool and task multiplexer. Pool will initialize with the configured number of workers. If supervisor configuration is provided -> pool will be turned into a supervisedExec mode -func NewPool(ctx context.Context, cmd pool.Command, factory pool.Factory, cfg *pool.Config, log *slog.Logger, options ...Options) (*Pool, error) { - if factory == nil { - return nil, errors.Str("no factory initialized") - } - +// NewPool creates a new worker pool. Pool will initialize with the configured number of workers. +// If supervisor configuration is provided -> pool will be supervised (TTL, idle TTL and memory checks). +func NewPool(ctx context.Context, cmd pool.Command, cfg *pool.Config, log *slog.Logger, options ...Options) (*Pool, error) { if cfg == nil { return nil, errors.Str("nil configuration provided") } @@ -66,19 +45,11 @@ func NewPool(ctx context.Context, cmd pool.Command, factory pool.Factory, cfg *p return nil, errors.Str("number of workers can't be more than 500") } - // for debug mode we need to set the number of workers to 0 (no pre-allocated workers) and max jobs to 1 - if cfg.Debug { - cfg.NumWorkers = 0 - cfg.MaxJobs = 1 - cfg.MaxQueueSize = 0 - } - p := &Pool{ - cfg: cfg, - cmd: cmd, - factory: factory, - log: log, - stopCh: make(chan struct{}), + cfg: cfg, + cmd: cmd, + log: log, + stopCh: make(chan struct{}), } // apply options @@ -91,7 +62,7 @@ func NewPool(ctx context.Context, cmd pool.Command, factory pool.Factory, cfg *p } // set up workers' allocator - p.allocator = pool.NewPoolAllocator(ctx, p.cfg.AllocateTimeout, p.cfg.MaxJobs, factory, cmd, p.cfg.Command, p.log) + p.allocator = pool.NewPoolAllocator(ctx, cmd, p.cfg.Command, p.log) // set up workers' watcher p.ww = workerWatcher.NewSyncWorkerWatcher(p.allocator, p.log, p.cfg.NumWorkers, p.cfg.AllocateTimeout) @@ -108,11 +79,6 @@ func NewPool(ctx context.Context, cmd pool.Command, factory pool.Factory, cfg *p } if p.cfg.Supervisor != nil { - if p.cfg.Supervisor.ExecTTL != 0 { - // we use supervisedExec ExecWithTTL mode only when ExecTTL is set - // otherwise we may use a faster Exec - p.supervisedExec = true - } // start the supervisor p.start() } @@ -135,10 +101,6 @@ func (sp *Pool) Workers() (workers []*worker.Process) { } func (sp *Pool) RemoveWorker(ctx context.Context) error { - if sp.cfg.Debug { - sp.log.Warn("remove worker operation is not allowed in debug mode") - return nil - } var cancel context.CancelFunc _, ok := ctx.Deadline() if !ok { @@ -150,228 +112,9 @@ func (sp *Pool) RemoveWorker(ctx context.Context) error { } func (sp *Pool) AddWorker() error { - if sp.cfg.Debug { - sp.log.Warn("add worker operation is not allowed in debug mode") - return nil - } return sp.ww.AddWorker() } -// Exec executes provided payload on the worker -func (sp *Pool) Exec(ctx context.Context, p *payload.Payload, stopCh chan struct{}) (chan *PExec, error) { - const op = errors.Op("static_pool_exec") - - if len(p.Body) == 0 && len(p.Context) == 0 { - return nil, errors.E(op, errors.Str("payload can not be empty")) - } - - // check if we have space to put the request - if sp.maxQueueSize.Load() != 0 && sp.queue.Load() >= sp.maxQueueSize.Load() { - return nil, errors.E(op, errors.QueueSize, errors.Str("max queue size reached")) - } - - if sp.cfg.Debug { - switch sp.supervisedExec { - case true: - ctxTTL, cancel := context.WithTimeout(ctx, sp.cfg.Supervisor.ExecTTL) - defer cancel() - return sp.execDebug(ctxTTL, p, stopCh) - case false: - return sp.execDebug(context.Background(), p, stopCh) - } - } - - /* - register a request in the QUEUE - */ - sp.queue.Add(1) - defer sp.queue.Add(^uint64(0)) - - // see notes at the end of the file -begin: - ctxGetFree, cancel := context.WithTimeout(ctx, sp.cfg.AllocateTimeout) - defer cancel() - w, err := sp.takeWorker(ctxGetFree, op) - if err != nil { - return nil, errors.E(op, err) - } - - var rsp *payload.Payload - switch sp.supervisedExec { - case true: - // in the supervisedExec mode we're limiting the allowed time for the execution inside the PHP worker - ctxT, cancelT := context.WithTimeout(ctx, sp.cfg.Supervisor.ExecTTL) - defer cancelT() - rsp, err = w.Exec(ctxT, p) - case false: - // no context here - // potential problem: if the worker is hung, we can't stop it - rsp, err = w.Exec(context.Background(), p) - } - - if w.MaxExecsReached() { - sp.log.Debug("requests execution limit reached, worker will be restarted", "pid", w.Pid(), "execs", w.State().NumExecs()) - w.State().Transition(fsm.StateMaxJobsReached) - } - - if err != nil { - // just push event if on any stage was timeout error - switch { - case errors.Is(errors.ExecTTL, err): - // in this case, the worker already killed in the ExecTTL function - sp.log.Warn("worker stopped, and will be restarted", "reason", "execTTL timeout elapsed", "pid", w.Pid(), "internal_event_name", events.EventExecTTL.String(), "error", err) - w.State().Transition(fsm.StateExecTTLReached) - - // worker should already be reallocated - return nil, err - case errors.Is(errors.SoftJob, err): - /* - in case of soft job error, we should not kill the worker; this is just an error payload from the worker. - */ - w.State().Transition(fsm.StateReady) - sp.log.Warn("soft worker error", "reason", "SoftJob", "pid", w.Pid(), "internal_event_name", events.EventWorkerSoftError.String(), "error", err) - sp.ww.Release(w) - - return nil, err - case errors.Is(errors.Network, err): - // in case of network error, we can't stop the worker, we should kill it - w.State().Transition(fsm.StateErrored) - sp.log.Warn("RoadRunner can't communicate with the worker", "reason", "worker hung or process was killed", "pid", w.Pid(), "internal_event_name", events.EventWorkerError.String(), "error", err) - // kill the worker instead of sending a net packet to it - _ = w.Kill() - - // do not return it, should be reallocated on Kill - return nil, err - case errors.Is(errors.Retry, err): - // put the worker back to the stack and retry the request with the new one - sp.ww.Release(w) - goto begin - - default: - w.State().Transition(fsm.StateErrored) - sp.log.Warn("worker will be restarted", "pid", w.Pid(), "internal_event_name", events.EventWorkerDestruct.String(), "error", err) - - sp.ww.Release(w) - return nil, err - } - } - - // worker wants to be terminated - // unsafe is used to quickly transform []byte to string - if len(rsp.Body) == 0 && unsafe.String(unsafe.SliceData(rsp.Context), len(rsp.Context)) == StopRequest { - w.State().Transition(fsm.StateInvalid) - sp.ww.Release(w) - goto begin - } - - switch { - case rsp.Flags&frame.STREAM != 0: - sp.log.Debug("stream mode", "pid", w.Pid()) - // create a channel for the stream (only if there are no errors) - // we need to create a buffered channel to prevent blocking - // stream buffer size should be bigger than regular, to have some payloads ready (optimization) - resp := make(chan *PExec, 5) - // send the initial frame - resp <- newPExec(rsp, nil) - - // in case of stream, we should not return worker back immediately - go func() { //nolint:gosec // G118 - intentional: per-iteration exec timeout must be independent of request context - // would be called on Goexit - defer func() { - sp.log.Debug("release [stream] worker", "pid", w.Pid(), "state", w.State().String()) - close(resp) - sp.ww.Release(w) - }() - - // stream iterator - for { - select { - // we received a stop signal - case <-stopCh: - sp.log.Debug("stream stop signal received", "pid", w.Pid(), "state", w.State().String()) - ctxT, cancelT := context.WithTimeout(ctx, sp.cfg.StreamTimeout) - err = w.StreamCancel(ctxT) - cancelT() - if err != nil { - w.State().Transition(fsm.StateErrored) - sp.log.Warn("stream cancel error", "error", err) - } else { - // successfully canceled - w.State().Transition(fsm.StateReady) - sp.log.Debug("transition to the ready state", "from", w.State().String()) - } - - runtime.Goexit() - default: - // we have to set a stream timeout on every request - switch sp.supervisedExec { - case true: - ctxT, cancelT := context.WithTimeout(context.Background(), sp.cfg.Supervisor.ExecTTL) - pld, next, errI := w.StreamIterWithContext(ctxT) - cancelT() - if errI != nil { - sp.log.Warn("stream error", "error", errI) - - resp <- newPExec(nil, errI) - - // move worker to the invalid state to restart - w.State().Transition(fsm.StateInvalid) - runtime.Goexit() - } - - resp <- newPExec(pld, nil) - - if !next { - w.State().Transition(fsm.StateReady) - // we've got the last frame - runtime.Goexit() - } - case false: - // non supervised execution, can potentially hang here - pld, next, errI := w.StreamIter() - if errI != nil { - sp.log.Warn("stream iter error", "error", errI) - // send error response - resp <- newPExec(nil, errI) - - // move worker to the invalid state to restart - w.State().Transition(fsm.StateInvalid) - runtime.Goexit() - } - - resp <- newPExec(pld, nil) - - if !next { - w.State().Transition(fsm.StateReady) - // we've got the last frame - runtime.Goexit() - } - } - } - } - }() - - return resp, nil - default: - resp := make(chan *PExec, 1) - // send the initial frame - resp <- newPExec(rsp, nil) - sp.log.Debug("req-resp mode", "pid", w.Pid()) - if w.State().Compare(fsm.StateWorking) { - w.State().Transition(fsm.StateReady) - } - // return worker back - sp.ww.Release(w) - // close the channel - close(resp) - return resp, nil - } -} - -func (sp *Pool) QueueSize() uint64 { - return sp.queue.Load() -} - func (sp *Pool) NumDynamic() uint64 { if sp.cfg.DynamicAllocatorOpts == nil { return 0 @@ -390,7 +133,6 @@ func (sp *Pool) Destroy(ctx context.Context) { defer cancel() } sp.ww.Destroy(ctx) - sp.queue.Store(0) close(sp.stopCh) } @@ -413,32 +155,3 @@ func (sp *Pool) Reset(ctx context.Context) error { return nil } - -func (sp *Pool) takeWorker(ctxGetFree context.Context, op errors.Op) (*worker.Process, error) { - // Get function consumes context with timeout - w, err := sp.ww.Take(ctxGetFree) - if err != nil { - // if the error is of kind NoFreeWorkers, it means that we can't get worker from the stack during the allocate timeout - if errors.Is(errors.NoFreeWorkers, err) { - sp.log.Error( - "no free workers in the pool, wait timeout exceed", - "reason", "no free workers", - "internal_event_name", events.EventNoFreeWorkers.String(), - "error", err, - ) - - // if we don't have a dynamic allocator or in debug mode, we can't allocate a new worker - if sp.cfg.DynamicAllocatorOpts == nil || sp.cfg.Debug { - return nil, errors.E(op, errors.NoFreeWorkers) - } - - // for the dynamic allocator, we would have many requests waiting at the same time on the lock in the dyn allocator - // this will lead to the following case - all previous requests would be able to get the worker, since we're allocating them in the addMoreWorkers - // however, requests waiting for the lock won't allocate a new worker and would be failed - sp.dynamicAllocator.addMoreWorkers() - } - // else if err not nil - return error - return nil, errors.E(op, err) - } - return w, nil -} diff --git a/pool/static_pool/pool_test.go b/pool/static_pool/pool_test.go index 173c041..f987f8c 100644 --- a/pool/static_pool/pool_test.go +++ b/pool/static_pool/pool_test.go @@ -2,1346 +2,184 @@ package static_pool import ( "context" - l "log" - "os" + "log/slog" "os/exec" - "runtime" - "strconv" - "sync" + "sync/atomic" + "syscall" "testing" "time" - "unsafe" - - "log/slog" - "github.com/roadrunner-server/errors" "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/ipc/pipe" - "github.com/roadrunner-server/pool/v2/payload" "github.com/roadrunner-server/pool/v2/pool" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var testCfg = &pool.Config{ - NumWorkers: uint64(runtime.NumCPU()), - AllocateTimeout: time.Second * 500, - DestroyTimeout: time.Second * 500, -} - -func Test_NewPool(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, err) - resp := <-r - assert.Equal(t, []byte("hello"), resp.Body()) - - t.Cleanup(func() { p.Destroy(t.Context()) }) +// sleepCmd is a long-lived worker stand-in: no IPC, just an OS process +func sleepCmd(_ []string) *exec.Cmd { + return exec.Command("sleep", "300") } -func Test_MaxWorkers(t *testing.T) { - dynAllCfg := &pool.Config{ - NumWorkers: 501, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second, +func testCfg() *pool.Config { + return &pool.Config{ + NumWorkers: 2, + AllocateTimeout: time.Second * 10, + DestroyTimeout: time.Second * 10, } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/worker-slow-dyn.php") - }, - pipe.NewPipeFactory(slog.Default()), - dynAllCfg, - slog.Default(), - ) - assert.Error(t, err) - assert.Nil(t, p) } -func Test_NewPoolAddRemoveWorkers(t *testing.T) { - testCfg2 := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 500, - DestroyTimeout: time.Second * 500, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg2, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - resp := <-r - assert.Equal(t, []byte("hello"), resp.Body()) +func Test_NewPool(t *testing.T) { + p, err := NewPool(t.Context(), sleepCmd, testCfg(), slog.Default()) + require.NoError(t, err) + require.NotNil(t, p) - for range 100 { - err = p.AddWorker() - assert.NoError(t, err) + workers := p.Workers() + require.Len(t, workers, 2) + for i := range workers { + assert.True(t, workers[i].State().Compare(fsm.StateReady)) + assert.NotZero(t, workers[i].Pid()) } - err = p.AddWorker() - assert.NoError(t, err) - - err = p.RemoveWorker(t.Context()) - assert.NoError(t, err) - - err = p.RemoveWorker(t.Context()) - assert.NoError(t, err) - t.Cleanup(func() { p.Destroy(t.Context()) }) + p.Destroy(context.Background()) } -func Test_StaticPool_NilFactory(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - nil, - testCfg, - slog.Default(), - ) - assert.Error(t, err) - assert.Nil(t, p) -} - -func Test_StaticPool_NilConfig(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - nil, - slog.Default(), - ) - assert.Error(t, err) - assert.Nil(t, p) +func Test_NewPool_NilConfig(t *testing.T) { + _, err := NewPool(t.Context(), sleepCmd, nil, slog.Default()) + require.Error(t, err) + assert.Contains(t, err.Error(), "nil configuration provided") } -func Test_StaticPool_ImmediateDestroy(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - _, _ = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) +func Test_NewPool_MaxWorkers(t *testing.T) { + cfg := testCfg() + cfg.NumWorkers = 501 + _, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) + require.Error(t, err) + assert.Contains(t, err.Error(), "number of workers can't be more than 500") } -func Test_StaticPool_RemoveWorker(t *testing.T) { - testCfg2 := &pool.Config{ - NumWorkers: 5, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 5, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg2, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - wrks := p.Workers() - for range wrks { - assert.NoError(t, p.RemoveWorker(t.Context())) +func Test_NewPool_WrongCommand(t *testing.T) { + cmd := func(_ []string) *exec.Cmd { + return exec.Command("/non/existent/binary") } - // 1 worker should be in the pool - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - err = p.AddWorker() - assert.NoError(t, err) - - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - // after removing all workers, we should have 1 worker + 1 we added - assert.Len(t, p.Workers(), 2) - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) + // without the IPC handshake, a bad binary fails at cmd.Start() + _, err := NewPool(t.Context(), cmd, testCfg(), slog.Default()) + require.Error(t, err) } -func Test_Pool_Reallocate(t *testing.T) { - testCfg2 := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 500, - DestroyTimeout: time.Second * 500, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg2, - slog.Default(), - ) +func Test_NewPool_GetConfig(t *testing.T) { + cfg := testCfg() + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) require.NoError(t, err) - require.NotNil(t, p) - - wg := sync.WaitGroup{} - require.NoError(t, os.Rename("../../tests/client.php", "../../tests/client.bak")) - - wg.Go(func() { - for range 50 { - time.Sleep(time.Millisecond * 100) - _, errResp := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, errResp) - } - }) - - _ = p.Workers()[0].Kill() - time.Sleep(time.Second * 5) - require.NoError(t, os.Rename("../../tests/client.bak", "../../tests/client.php")) + assert.Same(t, cfg, p.GetConfig()) + assert.Equal(t, uint64(0), p.NumDynamic()) - wg.Wait() - - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) + p.Destroy(context.Background()) } -func Test_NewPoolReset(t *testing.T) { - testCfg2 := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 500, - DestroyTimeout: time.Second * 500, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg2, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - w := p.Workers() - if len(w) == 0 { - t.Fatal("should be workers inside") - } - pid := w[0].Pid() - - pldd, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, err) - pld := <-pldd - require.NotNil(t, pld.Body()) - - wg := &sync.WaitGroup{} - wg.Go(func() { - for range 100 { - time.Sleep(time.Millisecond * 10) - pldG, errG := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - require.NoError(t, errG) - pldGG := <-pldG - require.NotNil(t, pldGG.Body()) - } - }) - - require.NoError(t, p.Reset(t.Context())) - pldd, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) +func Test_NewPoolAddRemoveWorkers(t *testing.T) { + p, err := NewPool(t.Context(), sleepCmd, testCfg(), slog.Default()) require.NoError(t, err) - pld = <-pldd - require.NotNil(t, pld.Body()) - - w2 := p.Workers() - if len(w2) == 0 { - t.Fatal("should be workers inside") - } - - require.NotEqual(t, pid, w2[0].Pid()) - wg.Wait() - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} -func Test_StaticPool_Invalid(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/invalid.php") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) + require.NoError(t, p.AddWorker()) + require.Len(t, p.Workers(), 3) - assert.Nil(t, p) - assert.Error(t, err) -} - -func Test_ConfigNoErrorInitDefaults(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) + require.NoError(t, p.RemoveWorker(context.Background())) + require.Len(t, p.Workers(), 2) - assert.NotNil(t, p) - assert.NoError(t, err) - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) + p.Destroy(context.Background()) } -func Test_StaticPool_QueueSizeLimit(t *testing.T) { - testCfg2 := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 500, - DestroyTimeout: time.Second * 500, - } - ctx, cancel := context.WithTimeout(t.Context(), time.Second*500) - defer cancel() - - p, err := NewPool( - ctx, - // sleep for 10 seconds - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep-ttl.php") }, - pipe.NewPipeFactory(slog.Default()), - testCfg2, - slog.Default(), - WithQueueSize(1), - ) +func Test_NewPool_RemoveLastWorker(t *testing.T) { + cfg := testCfg() + cfg.NumWorkers = 1 + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) require.NoError(t, err) - assert.NotNil(t, p) - wg := &sync.WaitGroup{} + // the last worker can't be removed + require.NoError(t, p.RemoveWorker(context.Background())) + require.Len(t, p.Workers(), 1) - wg.Go(func() { - time.Sleep(time.Second * 2) - _, err1 := p.Exec(ctx, &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - require.Error(t, err1) - }) - wg.Go(func() { - time.Sleep(time.Second * 2) - _, err2 := p.Exec(ctx, &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - require.Error(t, err2) - }) - - re, err := p.Exec(ctx, &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.NoError(t, err) - res := <-re - - assert.NotNil(t, res) - assert.NotNil(t, res.Body()) - assert.Empty(t, res.Context()) - - assert.Equal(t, "hello world", res.Payload().String()) - wg.Wait() - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) + p.Destroy(context.Background()) } -func Test_StaticPool_Echo(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.NoError(t, err) - - assert.NotNil(t, p) - - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.NoError(t, err) - res := <-re - - assert.NotNil(t, res) - assert.NotNil(t, res.Body()) - assert.Empty(t, res.Context()) - - assert.Equal(t, "hello", res.Payload().String()) - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} - -func Test_StaticPool_Echo_NilContext(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.NoError(t, err) - - assert.NotNil(t, p) - - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - res := <-re - - assert.NotNil(t, res) - assert.NotNil(t, res.Body()) - assert.Empty(t, res.Context()) - - assert.Equal(t, "hello", res.Payload().String()) - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} - -func Test_StaticPool_Echo_Context(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "head", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.NoError(t, err) - - assert.NotNil(t, p) - - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: []byte("world")}, make(chan struct{})) - assert.NoError(t, err) - res := <-re - - assert.NotNil(t, res) - assert.Empty(t, res.Body()) - assert.NotNil(t, res.Context()) - - assert.Equal(t, "world", string(res.Context())) - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} - -func Test_StaticPool_JobError(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "error", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - time.Sleep(time.Second * 2) - - res, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.Error(t, err) - assert.Nil(t, res) - - if errors.Is(errors.SoftJob, err) == false { - t.Fatal("error should be of type errors.Exec") - } - - assert.Contains(t, err.Error(), "hello") - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} - -func Test_StaticPool_Broken_Replace(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "broken", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - time.Sleep(time.Second) - res, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.Error(t, err) - assert.Nil(t, res) - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} +func Test_NewPoolReset(t *testing.T) { + cfg := testCfg() + cfg.ResetTimeout = time.Second * 10 + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) + require.NoError(t, err) -func Test_StaticPool_Broken_FromOutside(t *testing.T) { - cfg2 := &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 5, + oldPids := map[int64]struct{}{} + for _, w := range p.Workers() { + oldPids[w.Pid()] = struct{}{} } + require.Len(t, oldPids, 2) - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfg2, - nil, - ) - assert.NoError(t, err) - assert.NotNil(t, p) - time.Sleep(time.Second) - - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.NoError(t, err) - - res := <-re - - assert.NotNil(t, res) - assert.NotNil(t, res.Body()) - assert.Empty(t, res.Context()) - - assert.Equal(t, "hello", res.Payload().String()) - assert.Equal(t, 1, len(p.Workers())) + require.NoError(t, p.Reset(context.Background())) - // first creation - time.Sleep(time.Second * 2) - // killing random worker and expecting pool to replace it - err = p.Workers()[0].Kill() - if err != nil { - t.Errorf("error killing the process: error %v", err) - } - - // re-creation - time.Sleep(time.Second * 2) - list := p.Workers() - for _, w := range list { - assert.Equal(t, fsm.StateReady, w.State().CurrentState()) + workers := p.Workers() + require.Len(t, workers, 2) + for _, w := range workers { + assert.NotContains(t, oldPids, w.Pid()) } - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} - -func Test_StaticPool_AllocateTimeout(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "delay", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Nanosecond * 1, - DestroyTimeout: time.Second * 2, - }, - slog.Default(), - ) - assert.Error(t, err) - if !errors.Is(errors.WorkerAllocate, err) { - t.Fatal("error should be of type WorkerAllocate") - } - assert.Nil(t, p) + p.Destroy(context.Background()) } -func Test_StaticPool_Replace_Worker(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "pid", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - MaxJobs: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - // prevent process is not ready - time.Sleep(time.Second) - - var lastPID string - lastPID = strconv.Itoa(int(p.Workers()[0].Pid())) - - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) +func Test_StaticPool_ImmediateDestroy(t *testing.T) { + p, err := NewPool(t.Context(), sleepCmd, testCfg(), slog.Default()) require.NoError(t, err) - res := <-re - - require.Equal(t, lastPID, string(res.Body())) - - for range 10 { - re, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - require.NoError(t, err) - - res = <-re - - require.NotNil(t, res) - require.NotNil(t, res.Body()) - require.Empty(t, res.Context()) - - require.NotEqual(t, lastPID, string(res.Body())) - lastPID = string(res.Body()) - } - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} - -func Test_StaticPool_DebugAddRemove(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "pid", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: true, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - // prevent process is not ready - time.Sleep(time.Second) - assert.Len(t, p.Workers(), 0) - - var lastPID string - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.NoError(t, err) - - res := <-re - - assert.NotEqual(t, lastPID, string(res.Body())) - - assert.Len(t, p.Workers(), 0) - - err = p.AddWorker() - assert.NoError(t, err) - - assert.Len(t, p.Workers(), 0) - - ctxT, cancel := context.WithTimeout(t.Context(), time.Microsecond) - err = p.RemoveWorker(ctxT) - assert.NoError(t, err) + ctx, cancel := context.WithCancel(context.Background()) cancel() - - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} - -func Test_StaticPool_Debug_Worker(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "pid", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: true, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - // prevent process is not ready - time.Sleep(time.Second) - assert.Len(t, p.Workers(), 0) - - var lastPID string - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.NoError(t, err) - - res := <-re - - assert.NotEqual(t, lastPID, string(res.Body())) - - assert.Len(t, p.Workers(), 0) - - for range 10 { - assert.Len(t, p.Workers(), 0) - re, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.NoError(t, err) - res = <-re - - assert.NotNil(t, res) - assert.NotNil(t, res.Body()) - assert.Empty(t, res.Context()) - - assert.NotEqual(t, lastPID, string(res.Body())) - lastPID = string(res.Body()) - } - - t.Cleanup(func() { - p.Destroy(t.Context()) - p = nil - }) -} - -// identical to replace but controlled on worker side -func Test_Static_Pool_Destroy_And_Close(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "delay", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - - assert.NotNil(t, p) - assert.NoError(t, err) - - t.Cleanup(func() { - p.Destroy(t.Context()) - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("100")}, make(chan struct{})) - assert.Error(t, err) - p = nil - }) -} - -// identical to replace but controlled on worker side -func Test_Static_Pool_Destroy_And_Close_While_Wait(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "delay", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - - assert.NotNil(t, p) - assert.NoError(t, err) - - go func() { - _, errP := p.Exec(t.Context(), &payload.Payload{Body: []byte("100")}, make(chan struct{})) - assert.NoError(t, errP) - }() - - time.Sleep(time.Second * 2) - - t.Cleanup(func() { - p.Destroy(t.Context()) - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("100")}, make(chan struct{})) - assert.Error(t, err) - p = nil - }) + // already canceled context: workers are stopped via the ctx.Done path + p.Destroy(ctx) } -// identical to replace but controlled on worker side func Test_Static_Pool_Handle_Dead(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/slow-destroy.php", "echo", "pipes") - }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 5, - AllocateTimeout: time.Second * 100, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - time.Sleep(time.Second) - - for i := range p.Workers() { - p.Workers()[i].State().Transition(fsm.StateErrored) - } - - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.NoError(t, err) - p.Destroy(t.Context()) -} - -// identical to replace but controlled on worker side -func Test_Static_Pool_Slow_Destroy(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/slow-destroy.php", "echo", "pipes") - }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 5, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - - assert.NoError(t, err) - assert.NotNil(t, p) - p.Destroy(t.Context()) -} - -func Test_StaticPool_ResetTimeout(t *testing.T) { - p, err := NewPool( - t.Context(), - // sleep for the 3 seconds - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: false, - NumWorkers: 2, - AllocateTimeout: time.Second * 100, - DestroyTimeout: time.Second * 100, - ResetTimeout: time.Second * 3, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - go func() { - _, _ = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - }() - - time.Sleep(time.Second) - - err = p.Reset(t.Context()) - assert.NoError(t, err) - - t.Cleanup(func() { p.Destroy(t.Context()) }) -} - -func Test_StaticPool_NoFreeWorkers(t *testing.T) { - p, err := NewPool( - t.Context(), - // sleep for the 3 seconds - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: false, - NumWorkers: 1, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: nil, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - go func() { - _, _ = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - }() - - time.Sleep(time.Second) - res, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.Error(t, err) - assert.Nil(t, res) - - time.Sleep(time.Second) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} - -// identical to replace but controlled on worker side -func Test_StaticPool_Stop_Worker(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "stop", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - t.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - p.Destroy(ctx) - }) - time.Sleep(time.Second) - - var lastPID string - lastPID = strconv.Itoa(int(p.Workers()[0].Pid())) - - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.NoError(t, err) - - res := <-re - - assert.Equal(t, lastPID, string(res.Body())) - - for range 10 { - re, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - - res := <-re - - assert.NoError(t, err) - assert.NotNil(t, res) - assert.NotNil(t, res.Body()) - assert.Empty(t, res.Context()) - - assert.NotEqual(t, lastPID, string(res.Body())) - lastPID = string(res.Body()) - } -} - -func Test_StaticPool_QueueSize(t *testing.T) { - p, err := NewPool( - t.Context(), - // sleep for the 3 seconds - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep_short.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: false, - NumWorkers: 1, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: nil, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - for range 10 { - go func() { - _, _ = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - }() - } - - time.Sleep(time.Second) - require.LessOrEqual(t, p.QueueSize(), uint64(10)) - time.Sleep(time.Second * 20) - require.Less(t, p.QueueSize(), uint64(10)) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} - -// identical to replace but controlled on worker side -func Test_Static_Pool_WrongCommand1(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("phg", "../../tests/slow-destroy.php", "echo", "pipes") - }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 5, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - - assert.Error(t, err) - assert.Nil(t, p) -} - -// identical to replace but controlled on worker side -func Test_Static_Pool_WrongCommand2(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 5, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - - assert.Error(t, err) - assert.Nil(t, p) -} - -func Test_CRC_WithPayload(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/crc_error.php") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - assert.Error(t, err) - data := err.Error() - assert.Contains(t, data, "warning: some weird php erro") - require.Nil(t, p) -} - -/* - PTR: - -Benchmark_Pool_Echo-32 49076 29926 ns/op 8016 B/op 20 allocs/op -Benchmark_Pool_Echo-32 47257 30779 ns/op 8047 B/op 20 allocs/op -Benchmark_Pool_Echo-32 46737 29440 ns/op 8065 B/op 20 allocs/op -Benchmark_Pool_Echo-32 51177 29074 ns/op 7981 B/op 20 allocs/op -Benchmark_Pool_Echo-32 51764 28319 ns/op 8012 B/op 20 allocs/op -Benchmark_Pool_Echo-32 54054 30714 ns/op 7987 B/op 20 allocs/op -Benchmark_Pool_Echo-32 54391 30689 ns/op 8055 B/op 20 allocs/op - -VAL: -Benchmark_Pool_Echo-32 47936 28679 ns/op 7942 B/op 19 allocs/op -Benchmark_Pool_Echo-32 49010 29830 ns/op 7970 B/op 19 allocs/op -Benchmark_Pool_Echo-32 46771 29031 ns/op 8014 B/op 19 allocs/op -Benchmark_Pool_Echo-32 47760 30517 ns/op 7955 B/op 19 allocs/op -Benchmark_Pool_Echo-32 48148 29816 ns/op 7950 B/op 19 allocs/op -Benchmark_Pool_Echo-32 52705 29809 ns/op 7979 B/op 19 allocs/op -Benchmark_Pool_Echo-32 54374 27776 ns/op 7947 B/op 19 allocs/op -*/ -func Benchmark_Pool_Echo(b *testing.B) { - p, err := NewPool( - b.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - if err != nil { - b.Fatal(err) - } - - bd := make([]byte, 1024) - c := make([]byte, 1024) - - pld := &payload.Payload{ - Context: c, - Body: bd, - } - - b.ReportAllocs() - sc := make(chan struct{}) - for b.Loop() { - _, err = p.Exec(b.Context(), pld, sc) - assert.NoError(b, err) - } -} - -// Benchmark_Pool_Echo_Batched-32 366996 2873 ns/op 1233 B/op 24 allocs/op -// PTR -> Benchmark_Pool_Echo_Batched-32 406839 2900 ns/op 1059 B/op 23 allocs/op -// PTR -> Benchmark_Pool_Echo_Batched-32 413312 2904 ns/op 1067 B/op 23 allocs/op -func Benchmark_Pool_Echo_Batched(b *testing.B) { - p, err := NewPool( - b.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: uint64(runtime.NumCPU()), //nolint:gosec - AllocateTimeout: time.Second * 100, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - assert.NoError(b, err) - b.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - p.Destroy(ctx) - }) + cfg := testCfg() + cfg.NumWorkers = 1 + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) + require.NoError(t, err) - bd := make([]byte, 1024) - c := make([]byte, 1024) + oldPid := p.Workers()[0].Pid() + require.NotZero(t, oldPid) - pld := &payload.Payload{ - Context: c, - Body: bd, - } + // kill the worker process behind the pool's back + require.NoError(t, syscall.Kill(int(oldPid), syscall.SIGKILL)) - b.ReportAllocs() + // the watcher must observe the death and allocate a replacement + require.Eventually(t, func() bool { + workers := p.Workers() + return len(workers) == 1 && workers[0].Pid() != oldPid && workers[0].State().Compare(fsm.StateReady) + }, 10*time.Second, 50*time.Millisecond) - var wg sync.WaitGroup - sc := make(chan struct{}) - for b.Loop() { - wg.Go(func() { - if _, err := p.Exec(b.Context(), pld, sc); err != nil { - b.Fail() - l.Println(err) - } - }) - } - - wg.Wait() + p.Destroy(context.Background()) } -// Benchmark_Pool_Echo_Replaced-32 104/100 10900218 ns/op 52365 B/op 125 allocs/op -func Benchmark_Pool_Echo_Replaced(b *testing.B) { - p, err := NewPool( - b.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - MaxJobs: 1, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - }, - slog.Default(), - ) - assert.NoError(b, err) - b.Cleanup(func() { - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - p.Destroy(ctx) - }) - - b.ReportAllocs() - - sc := make(chan struct{}) - for b.Loop() { - if _, err := p.Exec(b.Context(), &payload.Payload{Body: []byte("hello")}, sc); err != nil { - b.Fail() - l.Println(err) +func Test_NewPool_InstantExitWorker(t *testing.T) { + // without the IPC handshake the spawn of an instantly-exiting worker succeeds; + // the death is observed by the watcher, which keeps re-allocating. + // After a few exits the command turns long-lived so the churn settles and + // the pool can be destroyed cleanly. + var spawns atomic.Uint64 + cmd := func(_ []string) *exec.Cmd { + if spawns.Add(1) <= 3 { + return exec.Command("sh", "-c", "exit 1") } + return exec.Command("sleep", "300") } -} -// BenchmarkToStringUnsafe-12 566317729 1.91 ns/op 0 B/op 0 allocs/op -// BenchmarkToStringUnsafe-32 1000000000 0.4434 ns/op 0 B/op 0 allocs/op -func BenchmarkToStringUnsafe(b *testing.B) { - testPayload := []byte( - "falsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtoj", - ) - - b.ReportAllocs() - - for b.Loop() { - res := unsafe.String(unsafe.SliceData(testPayload), len(testPayload)) - _ = res - } -} - -// BenchmarkToStringSafe-32 8017846 182.5 ns/op 896 B/op 1 allocs/op -// inline BenchmarkToStringSafe-12 28926276 46.6 ns/op 128 B/op 1 allocs/op -func BenchmarkToStringSafe(b *testing.B) { - testPayload := []byte( - "falsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtojfalsflasjlifjwpoihejfoiwejow{}{}{}{}jelfjasjfhwaopiehjtopwhtgohrgouahsgkljasdlfjasl;fjals;jdflkndgouwhetopwqhjtoj", - ) - - b.ReportAllocs() - - for b.Loop() { - res := toStringNotFun(testPayload) - _ = res - } -} - -func toStringNotFun(data []byte) string { - return string(data) -} - -// ==================== Pool Lifecycle Edge Cases ==================== - -// TestPool_ExecAfterDestroy verifies that Exec after Destroy returns a clean error, not a panic. -// In production, HTTP handlers may hold pool references after config-reload triggers Destroy. -func TestPool_ExecAfterDestroy(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 5, - }, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - - // Verify pool works before destroy - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) + cfg := testCfg() + cfg.NumWorkers = 1 + p, err := NewPool(t.Context(), cmd, cfg, slog.Default()) require.NoError(t, err) - resp := <-r - assert.Equal(t, []byte("hello"), resp.Body()) - // Destroy the pool - p.Destroy(t.Context()) + require.Eventually(t, func() bool { + workers := p.Workers() + return spawns.Load() >= 4 && len(workers) == 1 && workers[0].State().Compare(fsm.StateReady) + }, 10*time.Second, 50*time.Millisecond) - // Exec after Destroy — must return WatcherStopped error, not panic - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - require.Error(t, err) - assert.True(t, errors.Is(errors.WatcherStopped, err), "expected WatcherStopped, got: %v", err) -} - -// TestPool_MaxQueueSize_Zero_AllowsRequests verifies that QueueSize=0 means unlimited. -// The `maxQueueSize != 0` guard in Exec() is essential. -// Without it, 0 >= 0 would block ALL requests. -func TestPool_MaxQueueSize_Zero_AllowsRequests(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: uint64(runtime.NumCPU()), //nolint:gosec - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 5, - }, - slog.Default(), - WithQueueSize(0), // explicitly zero - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - // All 10 sequential requests should succeed - for range 10 { - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - require.NoError(t, err) - resp := <-r - assert.Equal(t, []byte("hello"), resp.Body()) - } -} - -// TestPool_Reset_WhileExecInProgress verifies Reset works while an Exec is in progress. -// This is the SIGHUP / config reload scenario. Reset must wait for in-flight work. -func TestPool_Reset_WhileExecInProgress(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep_short.php") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 10, - DestroyTimeout: time.Second * 10, - ResetTimeout: time.Second * 10, - }, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - // Get initial worker PID - workers := p.Workers() - require.Len(t, workers, 1) - pidBefore := workers[0].Pid() - - // Start a slow exec in the background - done := make(chan struct{}) - go func() { - defer close(done) - _, _ = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - }() - - // Give the exec time to start - time.Sleep(time.Millisecond * 500) - - // Reset while exec is in progress - err = p.Reset(t.Context()) - require.NoError(t, err) - - // Wait for in-flight exec to complete - <-done - - // Pool should work with a new worker - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - require.NoError(t, err) - resp := <-r - assert.NotNil(t, resp.Body()) - - // New worker should have different PID - workers2 := p.Workers() - require.Len(t, workers2, 1) - assert.NotEqual(t, pidBefore, workers2[0].Pid()) -} - -// TestPool_ConcurrentExec_MaxJobs1 verifies pool works correctly when workers are replaced after each job. -// MaxJobs=1 triggers replacement after every request. If the replacement races with next Exec, deadlock. -func TestPool_ConcurrentExec_MaxJobs1(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "pid", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 2, - MaxJobs: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 5, - }, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - time.Sleep(time.Second) - - // Track PIDs across sequential requests - seenPIDs := make(map[string]struct{}) - - for range 10 { - r, err := p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - require.NoError(t, err) - resp := <-r - require.NotNil(t, resp.Body()) - seenPIDs[string(resp.Body())] = struct{}{} - } - - // With MaxJobs=1, workers get replaced — we should see multiple different PIDs - assert.Greater(t, len(seenPIDs), 1, "should see different PIDs due to MaxJobs=1 replacement") -} - -// TestPool_ExecEmptyPayload verifies that Exec with empty payload returns a clear error. -func TestPool_ExecEmptyPayload(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - testCfg, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - _, err = p.Exec(t.Context(), &payload.Payload{Body: nil, Context: nil}, make(chan struct{})) - require.Error(t, err) - assert.Contains(t, err.Error(), "payload can not be empty") + p.Destroy(context.Background()) } diff --git a/pool/static_pool/stream.go b/pool/static_pool/stream.go deleted file mode 100644 index 82ec029..0000000 --- a/pool/static_pool/stream.go +++ /dev/null @@ -1,31 +0,0 @@ -package static_pool - -import "github.com/roadrunner-server/pool/v2/payload" - -type PExec struct { - pld *payload.Payload - err error -} - -func newPExec(pld *payload.Payload, err error) *PExec { - return &PExec{ - pld: pld, - err: err, - } -} - -func (p *PExec) Payload() *payload.Payload { - return p.pld -} - -func (p *PExec) Body() []byte { - return p.pld.Body -} - -func (p *PExec) Context() []byte { - return p.pld.Context -} - -func (p *PExec) Error() error { - return p.err -} diff --git a/pool/static_pool/supervisor_test.go b/pool/static_pool/supervisor_test.go index 1428496..3be13d4 100644 --- a/pool/static_pool/supervisor_test.go +++ b/pool/static_pool/supervisor_test.go @@ -5,802 +5,183 @@ import ( "log/slog" "os" "os/exec" + "runtime" "testing" "time" "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/ipc/pipe" - "github.com/roadrunner-server/pool/v2/payload" "github.com/roadrunner-server/pool/v2/pool" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -var cfgSupervised = &pool.Config{ - NumWorkers: uint64(1), - AllocateTimeout: time.Second * 10, - DestroyTimeout: time.Second * 10, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 100 * time.Second, - IdleTTL: 100 * time.Second, - ExecTTL: 100 * time.Second, - MaxWorkerMemory: 100, - }, -} - -func Test_SupervisedPool_Exec(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/memleak.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgSupervised, - slog.Default(), - ) - - require.NoError(t, err) - require.NotNil(t, p) - - time.Sleep(time.Second) - - pidBefore := p.Workers()[0].Pid() - - for range 10 { - time.Sleep(time.Second) - _, err = p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - require.NoError(t, err) +// TestHelperProcess is not a real test: it is re-executed by the supervisor tests +// as a worker process that steadily grows its memory usage. +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_POOL_HELPER") != "memhog" { + return } - time.Sleep(time.Second) - require.NotEqual(t, pidBefore, p.Workers()[0].Pid()) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} - -func Test_SupervisedPool_AddRemoveWorkers(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/memleak.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgSupervised, - slog.Default(), - ) - - require.NoError(t, err) - require.NotNil(t, p) - - time.Sleep(time.Second) - - pidBefore := p.Workers()[0].Pid() - - for range 10 { - time.Sleep(time.Second) - _, err = p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - require.NoError(t, err) + hog := make([][]byte, 0, 100) + for range 100 { + b := make([]byte, 1<<20) + for i := range b { + b[i] = byte(i) + } + hog = append(hog, b) + time.Sleep(time.Millisecond * 5) } - - time.Sleep(time.Second) - require.NotEqual(t, pidBefore, p.Workers()[0].Pid()) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} - -func Test_SupervisedPool_ImmediateDestroy(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - AllocateTimeout: time.Second * 10, - DestroyTimeout: time.Second * 10, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 100 * time.Second, - IdleTTL: 100 * time.Second, - ExecTTL: 100 * time.Second, - MaxWorkerMemory: 100, - }, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - _, _ = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - - ctx, cancel := context.WithTimeout(t.Context(), time.Nanosecond) - defer cancel() - - p.Destroy(ctx) + runtime.KeepAlive(hog) + select {} } -func Test_SupervisedPool_NilFactory(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - nil, - slog.Default(), - ) - assert.Error(t, err) - assert.Nil(t, p) +func memhogCmd(_ []string) *exec.Cmd { + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess$") //nolint:gosec // re-executes the test binary itself + cmd.Env = append(os.Environ(), "GO_POOL_HELPER=memhog") + return cmd } -func Test_SupervisedPool_NilConfig(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - nil, - cfgSupervised, - slog.Default(), - ) - assert.Error(t, err) - assert.Nil(t, p) -} - -func Test_SupervisedPool_RemoveNoWorkers(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgSupervised, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - wrks := p.Workers() - for range wrks { - assert.NoError(t, p.RemoveWorker(t.Context())) +func supervisedCfg(s *pool.SupervisorConfig) *pool.Config { + return &pool.Config{ + NumWorkers: 1, + AllocateTimeout: time.Second * 10, + DestroyTimeout: time.Second * 10, + Supervisor: s, } - - assert.Len(t, p.Workers(), 1) - t.Cleanup(func() { p.Destroy(t.Context()) }) } -func Test_SupervisedPool_RemoveWorker(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgSupervised, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - wrks := p.Workers() - for range wrks { - assert.NoError(t, p.RemoveWorker(t.Context())) - } - - // should not be error, 1 worker should be in the pool - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - err = p.AddWorker() - assert.NoError(t, err) - - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: nil}, make(chan struct{})) - assert.NoError(t, err) - - assert.Len(t, p.Workers(), 2) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} - -func Test_SupervisedPoolReset(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/client.php", "echo", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgSupervised, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - w := p.Workers() - if len(w) == 0 { - t.Fatal("should be workers inside") - } - - pid := w[0].Pid() - require.NoError(t, p.Reset(t.Context())) +func Test_SupervisedPool_TTL_WorkerRestarted(t *testing.T) { + cfg := supervisedCfg(&pool.SupervisorConfig{ + WatchTick: time.Millisecond * 200, + TTL: time.Second, + }) - w2 := p.Workers() - if len(w2) == 0 { - t.Fatal("should be workers inside") - } + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) + require.NoError(t, err) - require.NotEqual(t, pid, w2[0].Pid()) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} + oldPid := p.Workers()[0].Pid() -// This test should finish without freezes -func TestSupervisedPool_ExecWithDebugMode(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/supervised.php") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: true, - NumWorkers: uint64(1), - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 100 * time.Second, - IdleTTL: 100 * time.Second, - ExecTTL: 100 * time.Second, - MaxWorkerMemory: 100, - }, - }, - slog.Default(), - ) - - assert.NoError(t, err) - assert.NotNil(t, p) - - time.Sleep(time.Second) - - for range 10 { - time.Sleep(time.Second) - _, err = p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.NoError(t, err) - } - t.Cleanup(func() { p.Destroy(t.Context()) }) -} + // after the TTL elapses the worker is stopped and replaced + require.Eventually(t, func() bool { + workers := p.Workers() + return len(workers) == 1 && workers[0].Pid() != oldPid && workers[0].State().Compare(fsm.StateReady) + }, 10*time.Second, 100*time.Millisecond) -func TestSupervisedPool_ExecTTL_TimedOut(t *testing.T) { - var cfgExecTTL = &pool.Config{ - NumWorkers: uint64(1), - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 100 * time.Second, - IdleTTL: 100 * time.Second, - ExecTTL: 1 * time.Second, - MaxWorkerMemory: 100, - }, - } - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgExecTTL, - slog.Default(), - ) - - assert.NoError(t, err) - assert.NotNil(t, p) - - pid := p.Workers()[0].Pid() - - _, err = p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.Error(t, err) - - time.Sleep(time.Second * 1) - // should be new worker with new pid - assert.NotEqual(t, pid, p.Workers()[0].Pid()) - t.Cleanup(func() { p.Destroy(t.Context()) }) + p.Destroy(context.Background()) } -func TestSupervisedPool_TTL_WorkerRestarted(t *testing.T) { - var cfgExecTTL = &pool.Config{ - NumWorkers: uint64(1), - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 5 * time.Second, - }, - } - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep-ttl.php") }, - pipe.NewPipeFactory(slog.Default()), - cfgExecTTL, - slog.Default(), - ) - - assert.NoError(t, err) - assert.NotNil(t, p) - - pid := p.Workers()[0].Pid() - - respCh, err := p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.NoError(t, err) - - resp := <-respCh - - assert.Equal(t, string(resp.Body()), "hello world") - assert.Empty(t, resp.Context()) - - time.Sleep(time.Second) - assert.NotEqual(t, pid, p.Workers()[0].Pid()) - require.Equal(t, p.Workers()[0].State().CurrentState(), fsm.StateReady) - pid = p.Workers()[0].Pid() - - respCh, err = p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.NoError(t, err) - - resp = <-respCh - - assert.Equal(t, string(resp.Body()), "hello world") - assert.Empty(t, resp.Context()) - - time.Sleep(time.Second) - // should be new worker with new pid - assert.NotEqual(t, pid, p.Workers()[0].Pid()) - require.Equal(t, p.Workers()[0].State().CurrentState(), fsm.StateReady) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} - -func TestSupervisedPool_Idle(t *testing.T) { - var cfgExecTTL = &pool.Config{ - NumWorkers: uint64(1), - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 100 * time.Second, - IdleTTL: 1 * time.Second, - ExecTTL: 100 * time.Second, - MaxWorkerMemory: 100, - }, - } - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/idle.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgExecTTL, - slog.Default(), - ) +func Test_SupervisedPool_MaxMemoryReached(t *testing.T) { + cfg := supervisedCfg(&pool.SupervisorConfig{ + WatchTick: time.Millisecond * 200, + MaxWorkerMemory: 32, // MB; the helper grows to ~100MB + }) - assert.NoError(t, err) - assert.NotNil(t, p) - - pid := p.Workers()[0].Pid() - - respCh, err := p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.NoError(t, err) - - resp := <-respCh - - assert.Empty(t, resp.Body()) - assert.Empty(t, resp.Context()) + p, err := NewPool(t.Context(), memhogCmd, cfg, slog.Default()) + require.NoError(t, err) - time.Sleep(time.Second * 5) + oldPid := p.Workers()[0].Pid() - // worker should be marked as invalid and reallocated - _, err = p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) + require.Eventually(t, func() bool { + workers := p.Workers() + return len(workers) == 1 && workers[0].Pid() != oldPid + }, 15*time.Second, 100*time.Millisecond) - assert.NoError(t, err) - require.Len(t, p.Workers(), 1) - // should be new worker with new pid - assert.NotEqual(t, pid, p.Workers()[0].Pid()) - t.Cleanup(func() { p.Destroy(t.Context()) }) + p.Destroy(context.Background()) } -func TestSupervisedPool_IdleTTL_StateAfterTimeout(t *testing.T) { - var cfgExecTTL = &pool.Config{ - NumWorkers: uint64(1), - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - IdleTTL: 1 * time.Second, - MaxWorkerMemory: 100, - }, - } - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/exec_ttl.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgExecTTL, - slog.Default(), - ) - - assert.NoError(t, err) - assert.NotNil(t, p) - - pid := p.Workers()[0].Pid() - - time.Sleep(time.Second) - respCh, err := p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.NoError(t, err) - - resp := <-respCh +func Test_SupervisedPool_IdleTTL_SkipsNeverUsedWorker(t *testing.T) { + cfg := supervisedCfg(&pool.SupervisorConfig{ + WatchTick: time.Millisecond * 200, + IdleTTL: time.Second, + }) - assert.Empty(t, resp.Body()) - assert.Empty(t, resp.Context()) + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) + require.NoError(t, err) - time.Sleep(time.Second * 5) + oldPid := p.Workers()[0].Pid() - if len(p.Workers()) < 1 { - t.Fatal("should be at least 1 worker") - return - } + // LastUsed == 0 (never marked as used): the idle check must not fire + time.Sleep(time.Second * 2) - // should be destroyed, state should be Ready, not Invalid - assert.NotEqual(t, pid, p.Workers()[0].Pid()) - assert.Equal(t, fsm.StateReady, p.Workers()[0].State().CurrentState()) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} + workers := p.Workers() + require.Len(t, workers, 1) + assert.Equal(t, oldPid, workers[0].Pid()) + assert.True(t, workers[0].State().Compare(fsm.StateReady)) -func TestSupervisedPool_ExecTTL_OK(t *testing.T) { - var cfgExecTTL = &pool.Config{ - NumWorkers: uint64(1), - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 100 * time.Second, - IdleTTL: 100 * time.Second, - ExecTTL: 4 * time.Second, - MaxWorkerMemory: 100, - }, - } - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/exec_ttl.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgExecTTL, - slog.Default(), - ) - - assert.NoError(t, err) - assert.NotNil(t, p) - - pid := p.Workers()[0].Pid() - - time.Sleep(time.Millisecond * 100) - respCh, err := p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.NoError(t, err) - - resp := <-respCh - - assert.Empty(t, resp.Body()) - assert.Empty(t, resp.Context()) - - time.Sleep(time.Second * 1) - // should be the same pid - assert.Equal(t, pid, p.Workers()[0].Pid()) - t.Cleanup(func() { p.Destroy(t.Context()) }) + p.Destroy(context.Background()) } -func TestSupervisedPool_ShouldRespond(t *testing.T) { - var cfgExecTTL = &pool.Config{ - NumWorkers: uint64(1), - AllocateTimeout: time.Minute, - DestroyTimeout: time.Minute, - Supervisor: &pool.SupervisorConfig{ - ExecTTL: 90 * time.Second, - MaxWorkerMemory: 100, - }, - } +func Test_SupervisedPool_IdleTTL_Fires(t *testing.T) { + cfg := supervisedCfg(&pool.SupervisorConfig{ + WatchTick: time.Millisecond * 200, + IdleTTL: time.Second, + }) - // constructed - // max memory - // constructed - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { - return exec.Command("php", "../../tests/should-not-be-killed.php", "pipes") - }, - pipe.NewPipeFactory(slog.Default()), - cfgExecTTL, - slog.Default(), - ) - - assert.NoError(t, err) - assert.NotNil(t, p) - - respCh, err := p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.NoError(t, err) - - resp := <-respCh - - assert.Equal(t, []byte("alive"), resp.Body()) - assert.Empty(t, resp.Context()) - - time.Sleep(time.Second) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) + require.NoError(t, err) -func TestSupervisedPool_MaxMemoryReached(t *testing.T) { - var cfgExecTTL = &pool.Config{ - NumWorkers: uint64(1), - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 100 * time.Second, - IdleTTL: 100 * time.Second, - ExecTTL: 4 * time.Second, - MaxWorkerMemory: 1, - }, - } + w := p.Workers()[0] + oldPid := w.Pid() + // mark the worker as last used an hour ago; activity tracking is plugin-side now + w.State().SetLastUsed(uint64(time.Now().Add(-time.Hour).UnixNano())) - // constructed - // max memory - // constructed - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/memleak.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - cfgExecTTL, - slog.Default(), - ) - - assert.NoError(t, err) - assert.NotNil(t, p) - - respCh, err := p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) - assert.NoError(t, err) - - resp := <-respCh - - assert.Empty(t, resp.Body()) - assert.Empty(t, resp.Context()) - - time.Sleep(time.Second) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} + require.Eventually(t, func() bool { + workers := p.Workers() + return len(workers) == 1 && workers[0].Pid() != oldPid && workers[0].State().Compare(fsm.StateReady) + }, 10*time.Second, 100*time.Millisecond) -func Test_SupervisedPool_FastCancel(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep.php") }, - pipe.NewPipeFactory(slog.Default()), - cfgSupervised, - slog.Default(), - ) - assert.NoError(t, err) - - assert.NotNil(t, p) - - newCtx, cancel := context.WithTimeout(t.Context(), time.Second) - defer cancel() - _, err = p.Exec(newCtx, &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.Error(t, err) - assert.Contains(t, err.Error(), "context deadline exceeded") - t.Cleanup(func() { p.Destroy(t.Context()) }) + p.Destroy(context.Background()) } -func Test_SupervisedPool_AllocateFailedOK(t *testing.T) { - var cfgExecTTL = &pool.Config{ - NumWorkers: uint64(2), - AllocateTimeout: time.Second * 15, - DestroyTimeout: time.Second * 5, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 5 * time.Second, - }, - } - - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/allocate-failed.php") }, - pipe.NewPipeFactory(slog.Default()), - cfgExecTTL, - slog.Default(), - ) - - assert.NoError(t, err) - require.NotNil(t, p) - - time.Sleep(time.Second) +func Test_SupervisedPool_AddRemoveWorkers(t *testing.T) { + cfg := supervisedCfg(&pool.SupervisorConfig{ + WatchTick: time.Second, + TTL: time.Second * 100, + }) - // should be ok - _, err = p.Exec(t.Context(), &payload.Payload{ - Context: []byte(""), - Body: []byte("foo"), - }, make(chan struct{})) + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) require.NoError(t, err) - // after creating this file, PHP will fail - file, err := os.Create("break") - require.NoError(t, err) + require.NoError(t, p.AddWorker()) + require.Len(t, p.Workers(), 2) - time.Sleep(time.Second * 5) - assert.NoError(t, file.Close()) - assert.NoError(t, os.Remove("break")) + require.NoError(t, p.RemoveWorker(context.Background())) + require.Len(t, p.Workers(), 1) - defer func() { - if r := recover(); r != nil { - assert.Fail(t, "panic should not be fired!") - } else { - p.Destroy(t.Context()) - } - }() + p.Destroy(context.Background()) } -func Test_SupervisedPool_NoFreeWorkers(t *testing.T) { - p, err := NewPool( - t.Context(), - // sleep for the 3 seconds - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - Debug: false, - NumWorkers: 1, - AllocateTimeout: time.Second, - DestroyTimeout: time.Second, - Supervisor: &pool.SupervisorConfig{}, - }, - slog.Default(), - ) - assert.NoError(t, err) - assert.NotNil(t, p) - - go func() { - ctxNew, cancel := context.WithTimeout(t.Context(), time.Second*5) - defer cancel() - _, _ = p.Exec(ctxNew, &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - }() - - time.Sleep(time.Second) - _, err = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello")}, make(chan struct{})) - assert.Error(t, err) - - time.Sleep(time.Second) - t.Cleanup(func() { p.Destroy(t.Context()) }) -} +func Test_SupervisedPool_ImmediateDestroy(t *testing.T) { + cfg := supervisedCfg(&pool.SupervisorConfig{ + WatchTick: time.Second, + TTL: time.Second * 100, + }) -// ==================== Supervisor Edge Cases ==================== - -// TestSupervisor_TTL_WorkingWorker_GetsInvalid verifies that a working worker gets StateInvalid -// (not StateTTLReached) when TTL expires during request execution. -// The TTL branch in control(): if worker is Ready → StateTTLReached, else → StateInvalid (graceful). -func TestSupervisor_TTL_WorkingWorker_GetsInvalid(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep.php") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 10, - DestroyTimeout: time.Second * 10, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - TTL: 2 * time.Second, - }, - }, - slog.Default(), - ) + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - // Start a long exec (sleep.php sleeps for 300s) - go func() { - _, _ = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: []byte("")}, make(chan struct{})) - }() - - // Wait for TTL to be reached while worker is busy - time.Sleep(time.Second * 4) - - // Worker should be marked as Invalid (not TTLReached) - workers := p.Workers() - require.NotEmpty(t, workers) - state := workers[0].State().CurrentState() - // Worker should either be Invalid (marked by supervisor) or already replaced - // If replaced, it should be in Ready state with a different PID - assert.True(t, - state == fsm.StateInvalid || state == fsm.StateReady || state == fsm.StateWorking, - "expected Invalid/Ready/Working, got %d", state) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + p.Destroy(ctx) } -// TestSupervisor_IdleTTL_SkipsNeverUsedWorker verifies that workers with LastUsed==0 -// (never executed a request) are not killed by idle TTL check. -// On pool startup, all workers have LastUsed=0 — idle check must skip them. -func TestSupervisor_IdleTTL_SkipsNeverUsedWorker(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/idle.php", "pipes") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 5, - DestroyTimeout: time.Second * 5, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - IdleTTL: 1 * time.Second, - }, - }, - slog.Default(), - ) +func Test_SupervisedPool_Reset(t *testing.T) { + cfg := supervisedCfg(&pool.SupervisorConfig{ + WatchTick: time.Second, + TTL: time.Second * 100, + }) + cfg.ResetTimeout = time.Second * 10 + + p, err := NewPool(t.Context(), sleepCmd, cfg, slog.Default()) require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - pid := p.Workers()[0].Pid() + oldPid := p.Workers()[0].Pid() - // Wait 3 seconds WITHOUT executing any request - // The idle TTL check should skip this worker because LastUsed==0 - time.Sleep(time.Second * 3) + require.NoError(t, p.Reset(context.Background())) workers := p.Workers() require.Len(t, workers, 1) - // Worker should still be alive with the same PID (not replaced) - assert.Equal(t, pid, workers[0].Pid(), "never-used worker should not be killed by idle TTL") -} + assert.NotEqual(t, oldPid, workers[0].Pid()) -// TestSupervisor_MemoryCheck_WorkingWorkerGetsInvalid verifies that a working worker -// exceeding memory gets StateInvalid (not StateMaxMemoryReached). -// Same pattern as TTL in control(): killing a working worker corrupts in-flight response. -func TestSupervisor_MemoryCheck_WorkingWorkerGetsInvalid(t *testing.T) { - p, err := NewPool( - t.Context(), - func(cmd []string) *exec.Cmd { return exec.Command("php", "../../tests/sleep.php") }, - pipe.NewPipeFactory(slog.Default()), - &pool.Config{ - NumWorkers: 1, - AllocateTimeout: time.Second * 10, - DestroyTimeout: time.Second * 10, - Supervisor: &pool.SupervisorConfig{ - WatchTick: 1 * time.Second, - MaxWorkerMemory: 1, // 1 MB — very low, will be exceeded - }, - }, - slog.Default(), - ) - require.NoError(t, err) - require.NotNil(t, p) - t.Cleanup(func() { p.Destroy(t.Context()) }) - - // Start a long exec while worker is busy - go func() { - _, _ = p.Exec(t.Context(), &payload.Payload{Body: []byte("hello"), Context: []byte("")}, make(chan struct{})) - }() - - time.Sleep(time.Second * 3) - - // Worker should be marked Invalid while working (not MaxMemoryReached) - workers := p.Workers() - require.NotEmpty(t, workers) - state := workers[0].State().CurrentState() - assert.True(t, - state == fsm.StateInvalid || state == fsm.StateReady || state == fsm.StateWorking, - "expected Invalid/Ready/Working, got %d", state) + p.Destroy(context.Background()) } diff --git a/schema.json b/schema.json index a4556fc..cddf87f 100644 --- a/schema.json +++ b/schema.json @@ -6,11 +6,6 @@ "type": "object", "additionalProperties": false, "properties": { - "debug": { - "description": "In debug mode, workers are created immediately before RR passes jobs to them, exiting once the job completes. This defeats the purpose of a worker pool and should only be used during development.", - "type": "boolean", - "default": false - }, "command": { "type": "string", "description": "The command to use for the pool. If defined, this will override the value in `server.command` for this pool only.", @@ -24,18 +19,6 @@ "minimum": 0, "default": 0 }, - "max_jobs": { - "description": "The maximum number of executions a worker may perform before it is terminated and a new process is started. Zero or undefined means no limit.", - "type": "integer", - "minimum": 0, - "default": 0 - }, - "max_queue_size": { - "description": "The maximum size of the internal job queue. This is the limit of pending, incoming jobs that await worker allocation. After the limit is reached, all additional jobs will be rejected with an error. Zero or undefined means no limit.", - "type": "integer", - "minimum": 0, - "default": 0 - }, "allocate_timeout": { "description": "The maximum duration an incoming job is allowed to wait for a worker. Zero or undefined defaults to 60s.", "$ref": "#/$defs/duration", @@ -46,11 +29,6 @@ "$ref": "#/$defs/duration", "default": "60s" }, - "stream_timeout": { - "description": "The maximum duration to wait for stream cancellation. Zero or undefined defaults to 60s.", - "$ref": "#/$defs/duration", - "default": "60s" - }, "destroy_timeout": { "description": "The maximum duration to wait for worker termination/deallocation. If a worker has not stopped after this period, the process will be killed. Zero or undefined defaults to 60s.", "$ref": "#/$defs/duration", @@ -70,7 +48,7 @@ "default": 10 }, "spawn_rate": { - "description": "The maximum number of workers that may be spawned per NoFreeWorkers error (limited to `max_workers`).", + "description": "The maximum number of workers that may be spawned per scale-up trigger (limited to `max_workers`).", "type": "integer", "minimum": 1, "default": 5 @@ -108,11 +86,6 @@ "type": "integer", "minimum": 0, "default": 0 - }, - "exec_ttl": { - "description": "The maximum duration any job (hard limit) is allowed to take. If a job exceeds this time limit, the worker processing it will be terminated. Zero or undefined means no limit.", - "$ref": "#/$defs/duration", - "default": "0s" } } } diff --git a/tests/allocate-failed.php b/tests/allocate-failed.php deleted file mode 100644 index 8514ecc..0000000 --- a/tests/allocate-failed.php +++ /dev/null @@ -1,18 +0,0 @@ -waitPayload()){ - $rr->respond(new \Spiral\RoadRunner\Payload("")); -} diff --git a/tests/broken.php b/tests/broken.php deleted file mode 100644 index 413f860..0000000 --- a/tests/broken.php +++ /dev/null @@ -1,14 +0,0 @@ -waitPayload()) { - echo undefined_function(); - $rr->respond(new RoadRunner\Payload((string)$in->body, null)); -} diff --git a/tests/client.php b/tests/client.php deleted file mode 100644 index a5461ce..0000000 --- a/tests/client.php +++ /dev/null @@ -1,36 +0,0 @@ -=2.7", - "spiral/goridge": "^4.0", - "spiral/roadrunner-metrics": "^3.0" - }, - "autoload": { - "psr-4": { - "Temporal\\Tests\\": "src" - } - }, - "name": "test/test", - "description": "test" -} diff --git a/tests/crc_error.php b/tests/crc_error.php deleted file mode 100644 index a769fb1..0000000 --- a/tests/crc_error.php +++ /dev/null @@ -1,17 +0,0 @@ -waitPayload()){ - sleep(3); - $rr->respond(new \Spiral\RoadRunner\Payload("")); -} diff --git a/tests/delay.php b/tests/delay.php deleted file mode 100644 index 2c8255b..0000000 --- a/tests/delay.php +++ /dev/null @@ -1,18 +0,0 @@ -waitPayload()) { - try { - usleep($in->body * 1000); - $rr->respond(new RoadRunner\Payload('')); - } catch (\Throwable $e) { - $rr->error((string)$e); - } -} diff --git a/tests/echo.php b/tests/echo.php deleted file mode 100644 index 6451046..0000000 --- a/tests/echo.php +++ /dev/null @@ -1,17 +0,0 @@ -waitPayload()) { - try { - $rr->respond(new RoadRunner\Payload((string)$in->body)); - } catch (\Throwable $e) { - $rr->error((string)$e); - } -} diff --git a/tests/error.php b/tests/error.php deleted file mode 100644 index c77e681..0000000 --- a/tests/error.php +++ /dev/null @@ -1,13 +0,0 @@ -waitPayload()) { - $rr->error((string)$in->body); -} diff --git a/tests/exec_ttl.php b/tests/exec_ttl.php deleted file mode 100644 index fb5c9df..0000000 --- a/tests/exec_ttl.php +++ /dev/null @@ -1,15 +0,0 @@ -waitPayload()){ - sleep(3); - $rr->respond(new \Spiral\RoadRunner\Payload("")); -} diff --git a/tests/failboot.php b/tests/failboot.php deleted file mode 100644 index d59462c..0000000 --- a/tests/failboot.php +++ /dev/null @@ -1,3 +0,0 @@ -waitPayload()) { - try { - $rr->respond(new RoadRunner\Payload("", (string)$in->header)); - } catch (\Throwable $e) { - $rr->error((string)$e); - } -} diff --git a/tests/http/client.php b/tests/http/client.php deleted file mode 100644 index fa9865b..0000000 --- a/tests/http/client.php +++ /dev/null @@ -1,51 +0,0 @@ -waitRequest()) { - try { - $psr7->respond(handleRequest($req, new \Nyholm\Psr7\Response())); - } catch (\Throwable $e) { - $psr7->getWorker()->error((string)$e); - } -} diff --git a/tests/http/cookie.php b/tests/http/cookie.php deleted file mode 100644 index 97673ef..0000000 --- a/tests/http/cookie.php +++ /dev/null @@ -1,334 +0,0 @@ -getBody()->write(strtoupper($req->getCookieParams()['input'])); - - return $resp->withAddedHeader( - "Set-Cookie", - (new Cookie('output', 'cookie-output'))->createHeader() - ); -} - -final class Cookie -{ - /** - * The name of the cookie. - * - * @var string - */ - private $name = ''; - /** - * The value of the cookie. This value is stored on the clients computer; do not store sensitive - * information. - * - * @var string|null - */ - private $value = null; - /** - * Cookie lifetime. This value specified in seconds and declares period of time in which cookie - * will expire relatively to current time() value. - * - * @var int|null - */ - private $lifetime = null; - /** - * The path on the server in which the cookie will be available on. - * - * If set to '/', the cookie will be available within the entire domain. If set to '/foo/', - * the cookie will only be available within the /foo/ directory and all sub-directories such as - * /foo/bar/ of domain. The default value is the current directory that the cookie is being set - * in. - * - * @var string|null - */ - private $path = null; - /** - * The domain that the cookie is available. To make the cookie available on all subdomains of - * example.com then you'd set it to '.example.com'. The . is not required but makes it - * compatible with more browsers. Setting it to www.example.com will make the cookie only - * available in the www subdomain. Refer to tail matching in the spec for details. - * - * @var string|null - */ - private $domain = null; - /** - * Indicates that the cookie should only be transmitted over a secure HTTPS connection from the - * client. When set to true, the cookie will only be set if a secure connection exists. - * On the server-side, it's on the programmer to send this kind of cookie only on secure - * connection - * (e.g. with respect to $_SERVER["HTTPS"]). - * - * @var bool|null - */ - private $secure = null; - /** - * When true the cookie will be made accessible only through the HTTP protocol. This means that - * the cookie won't be accessible by scripting languages, such as JavaScript. This setting can - * effectively help to reduce identity theft through XSS attacks (although it is not supported - * by all browsers). - * - * @var bool - */ - private $httpOnly = true; - - /** - * New Cookie instance, cookies used to schedule cookie set while dispatching Response. - * - * @link http://php.net/manual/en/function.setcookie.php - * - * @param string $name The name of the cookie. - * @param string $value The value of the cookie. This value is stored on the clients - * computer; do not store sensitive information. - * @param int $lifetime Cookie lifetime. This value specified in seconds and declares period - * of time in which cookie will expire relatively to current time() - * value. - * @param string $path The path on the server in which the cookie will be available on. - * If set to '/', the cookie will be available within the entire - * domain. - * If set to '/foo/', the cookie will only be available within the - * /foo/ - * directory and all sub-directories such as /foo/bar/ of domain. The - * default value is the current directory that the cookie is being set - * in. - * @param string $domain The domain that the cookie is available. To make the cookie - * available - * on all subdomains of example.com then you'd set it to - * '.example.com'. - * The . is not required but makes it compatible with more browsers. - * Setting it to www.example.com will make the cookie only available in - * the www subdomain. Refer to tail matching in the spec for details. - * @param bool $secure Indicates that the cookie should only be transmitted over a secure - * HTTPS connection from the client. When set to true, the cookie will - * only be set if a secure connection exists. On the server-side, it's - * on the programmer to send this kind of cookie only on secure - * connection (e.g. with respect to $_SERVER["HTTPS"]). - * @param bool $httpOnly When true the cookie will be made accessible only through the HTTP - * protocol. This means that the cookie won't be accessible by - * scripting - * languages, such as JavaScript. This setting can effectively help to - * reduce identity theft through XSS attacks (although it is not - * supported by all browsers). - */ - public function __construct( - string $name, - string $value = null, - int $lifetime = null, - string $path = null, - string $domain = null, - bool $secure = false, - bool $httpOnly = true - ) { - $this->name = $name; - $this->value = $value; - $this->lifetime = $lifetime; - $this->path = $path; - $this->domain = $domain; - $this->secure = $secure; - $this->httpOnly = $httpOnly; - } - - /** - * The name of the cookie. - * - * @return string - */ - public function getName(): string - { - return $this->name; - } - - /** - * The value of the cookie. This value is stored on the clients computer; do not store sensitive - * information. - * - * @return string|null - */ - public function getValue() - { - return $this->value; - } - - /** - * The time the cookie expires. This is a Unix timestamp so is in number of seconds since the - * epoch. In other words, you'll most likely set this with the time function plus the number of - * seconds before you want it to expire. Or you might use mktime. - * - * Will return null if lifetime is not specified. - * - * @return int|null - */ - public function getExpires() - { - if ($this->lifetime === null) { - return null; - } - - return time() + $this->lifetime; - } - - /** - * The path on the server in which the cookie will be available on. - * - * If set to '/', the cookie will be available within the entire domain. If set to '/foo/', - * the cookie will only be available within the /foo/ directory and all sub-directories such as - * /foo/bar/ of domain. The default value is the current directory that the cookie is being set - * in. - * - * @return string|null - */ - public function getPath() - { - return $this->path; - } - - /** - * The domain that the cookie is available. To make the cookie available on all subdomains of - * example.com then you'd set it to '.example.com'. The . is not required but makes it - * compatible with more browsers. Setting it to www.example.com will make the cookie only - * available in the www subdomain. Refer to tail matching in the spec for details. - * - * @return string|null - */ - public function getDomain() - { - return $this->domain; - } - - /** - * Indicates that the cookie should only be transmitted over a secure HTTPS connection from the - * client. When set to true, the cookie will only be set if a secure connection exists. - * On the server-side, it's on the programmer to send this kind of cookie only on secure - * connection - * (e.g. with respect to $_SERVER["HTTPS"]). - * - * @return bool - */ - public function isSecure(): bool - { - return $this->secure; - } - - /** - * When true the cookie will be made accessible only through the HTTP protocol. This means that - * the cookie won't be accessible by scripting languages, such as JavaScript. This setting can - * effectively help to reduce identity theft through XSS attacks (although it is not supported - * by all browsers). - * - * @return bool - */ - public function isHttpOnly(): bool - { - return $this->httpOnly; - } - - /** - * Get new cookie with altered value. Original cookie object should not be changed. - * - * @param string $value - * - * @return Cookie - */ - public function withValue(string $value): self - { - $cookie = clone $this; - $cookie->value = $value; - - return $cookie; - } - - /** - * Convert cookie instance to string. - * - * @link http://www.w3.org/Protocols/rfc2109/rfc2109 - * @return string - */ - public function createHeader(): string - { - $header = [ - rawurlencode($this->name) . '=' . rawurlencode($this->value) - ]; - if ($this->lifetime !== null) { - $header[] = 'Expires=' . gmdate(\DateTime::COOKIE, $this->getExpires()); - $header[] = 'Max-Age=' . $this->lifetime; - } - if (!empty($this->path)) { - $header[] = 'Path=' . $this->path; - } - if (!empty($this->domain)) { - $header[] = 'Domain=' . $this->domain; - } - if ($this->secure) { - $header[] = 'Secure'; - } - if ($this->httpOnly) { - $header[] = 'HttpOnly'; - } - - return join('; ', $header); - } - - /** - * New Cookie instance, cookies used to schedule cookie set while dispatching Response. - * Static constructor. - * - * @link http://php.net/manual/en/function.setcookie.php - * - * @param string $name The name of the cookie. - * @param string $value The value of the cookie. This value is stored on the clients - * computer; do not store sensitive information. - * @param int $lifetime Cookie lifetime. This value specified in seconds and declares period - * of time in which cookie will expire relatively to current time() - * value. - * @param string $path The path on the server in which the cookie will be available on. - * If set to '/', the cookie will be available within the entire - * domain. - * If set to '/foo/', the cookie will only be available within the - * /foo/ - * directory and all sub-directories such as /foo/bar/ of domain. The - * default value is the current directory that the cookie is being set - * in. - * @param string $domain The domain that the cookie is available. To make the cookie - * available - * on all subdomains of example.com then you'd set it to - * '.example.com'. - * The . is not required but makes it compatible with more browsers. - * Setting it to www.example.com will make the cookie only available in - * the www subdomain. Refer to tail matching in the spec for details. - * @param bool $secure Indicates that the cookie should only be transmitted over a secure - * HTTPS connection from the client. When set to true, the cookie will - * only be set if a secure connection exists. On the server-side, it's - * on the programmer to send this kind of cookie only on secure - * connection (e.g. with respect to $_SERVER["HTTPS"]). - * @param bool $httpOnly When true the cookie will be made accessible only through the HTTP - * protocol. This means that the cookie won't be accessible by - * scripting - * languages, such as JavaScript. This setting can effectively help to - * reduce identity theft through XSS attacks (although it is not - * supported by all browsers). - * - * @return Cookie - */ - public static function create( - string $name, - string $value = null, - int $lifetime = null, - string $path = null, - string $domain = null, - bool $secure = false, - bool $httpOnly = true - ): self { - return new self($name, $value, $lifetime, $path, $domain, $secure, $httpOnly); - } - - /** - * @return string - */ - public function __toString(): string - { - return $this->createHeader(); - } -} diff --git a/tests/http/data.php b/tests/http/data.php deleted file mode 100644 index 6570936..0000000 --- a/tests/http/data.php +++ /dev/null @@ -1,17 +0,0 @@ -getParsedBody(); - - ksort($data); - ksort($data['arr']); - ksort($data['arr']['x']['y']); - - $resp->getBody()->write(json_encode($data)); - - return $resp; -} diff --git a/tests/http/echo.php b/tests/http/echo.php deleted file mode 100644 index 08e29a2..0000000 --- a/tests/http/echo.php +++ /dev/null @@ -1,10 +0,0 @@ -getBody()->write(strtoupper($req->getQueryParams()['hello'])); - return $resp->withStatus(201); -} diff --git a/tests/http/echoDelay.php b/tests/http/echoDelay.php deleted file mode 100644 index 78e8547..0000000 --- a/tests/http/echoDelay.php +++ /dev/null @@ -1,11 +0,0 @@ -getBody()->write(strtoupper($req->getQueryParams()['hello'])); - return $resp->withStatus(201); -} diff --git a/tests/http/echoerr.php b/tests/http/echoerr.php deleted file mode 100644 index 7e1d05e..0000000 --- a/tests/http/echoerr.php +++ /dev/null @@ -1,12 +0,0 @@ -getQueryParams()['hello'])); - - $resp->getBody()->write(strtoupper($req->getQueryParams()['hello'])); - return $resp->withStatus(201); -} diff --git a/tests/http/env.php b/tests/http/env.php deleted file mode 100644 index 3755bde..0000000 --- a/tests/http/env.php +++ /dev/null @@ -1,10 +0,0 @@ -getBody()->write($_SERVER['ENV_KEY']); - return $resp; -} diff --git a/tests/http/error.php b/tests/http/error.php deleted file mode 100644 index 527e406..0000000 --- a/tests/http/error.php +++ /dev/null @@ -1,9 +0,0 @@ -getBody()->write(strtoupper($req->getHeaderLine('input'))); - - return $resp->withAddedHeader("Header", $req->getQueryParams()['hello']); -} diff --git a/tests/http/headers.php b/tests/http/headers.php deleted file mode 100644 index b6f3967..0000000 --- a/tests/http/headers.php +++ /dev/null @@ -1,11 +0,0 @@ -getBody()->write(json_encode($req->getHeaders())); - - return $resp; -} diff --git a/tests/http/ip.php b/tests/http/ip.php deleted file mode 100644 index 49eb928..0000000 --- a/tests/http/ip.php +++ /dev/null @@ -1,11 +0,0 @@ -getBody()->write($req->getServerParams()['REMOTE_ADDR']); - - return $resp; -} diff --git a/tests/http/memleak.php b/tests/http/memleak.php deleted file mode 100644 index 197a7fb..0000000 --- a/tests/http/memleak.php +++ /dev/null @@ -1,11 +0,0 @@ -getHeaderLine("Content-Type") != 'application/json') { - $resp->getBody()->write("invalid content-type"); - return $resp; - } - - // we expect json body - $p = json_decode($req->getBody(), true); - $resp->getBody()->write(json_encode(array_flip($p))); - - return $resp; -} diff --git a/tests/http/pid.php b/tests/http/pid.php deleted file mode 100644 index f22d8e2..0000000 --- a/tests/http/pid.php +++ /dev/null @@ -1,11 +0,0 @@ -getBody()->write((string)getmypid()); - - return $resp; -} diff --git a/tests/http/push.php b/tests/http/push.php deleted file mode 100644 index d88fc07..0000000 --- a/tests/http/push.php +++ /dev/null @@ -1,10 +0,0 @@ -getBody()->write(strtoupper($req->getQueryParams()['hello'])); - return $resp->withAddedHeader("Http2-Push", __FILE__)->withStatus(201); -} diff --git a/tests/http/request-uri.php b/tests/http/request-uri.php deleted file mode 100644 index d4c8755..0000000 --- a/tests/http/request-uri.php +++ /dev/null @@ -1,10 +0,0 @@ -getBody()->write($_SERVER['REQUEST_URI']); - return $resp; -} diff --git a/tests/http/server.php b/tests/http/server.php deleted file mode 100644 index 393d962..0000000 --- a/tests/http/server.php +++ /dev/null @@ -1,11 +0,0 @@ -getBody()->write(strtoupper($req->getHeaderLine('input'))); - - return $resp->withAddedHeader("Header", $req->getQueryParams()['hello']); -} diff --git a/tests/http/slow-client.php b/tests/http/slow-client.php deleted file mode 100644 index be554a7..0000000 --- a/tests/http/slow-client.php +++ /dev/null @@ -1,52 +0,0 @@ -waitRequest()) { - try { - $psr7->respond(handleRequest($req, new \Nyholm\Psr7\Response())); - } catch (\Throwable $e) { - $psr7->getWorker()->error((string) $e); - } -} diff --git a/tests/http/stuck.php b/tests/http/stuck.php deleted file mode 100644 index 2dea057..0000000 --- a/tests/http/stuck.php +++ /dev/null @@ -1,11 +0,0 @@ -getBody()->write(strtoupper($req->getQueryParams()['hello'])); - return $resp->withStatus(201); -} diff --git a/tests/http/upload.php b/tests/http/upload.php deleted file mode 100644 index 5752624..0000000 --- a/tests/http/upload.php +++ /dev/null @@ -1,35 +0,0 @@ -getUploadedFiles(); - array_walk_recursive($files, function (&$v) { - /** - * @var \Psr\Http\Message\UploadedFileInterface $v - */ - - if ($v->getError()) { - $v = [ - 'name' => $v->getClientFilename(), - 'size' => $v->getSize(), - 'mime' => $v->getClientMediaType(), - 'error' => $v->getError(), - ]; - } else { - $v = [ - 'name' => $v->getClientFilename(), - 'size' => $v->getSize(), - 'mime' => $v->getClientMediaType(), - 'error' => $v->getError(), - 'sha512' => hash('sha512', $v->getStream()->__toString()), - ]; - } - }); - - $resp->getBody()->write(json_encode($files, JSON_UNESCAPED_SLASHES)); - - return $resp; -} diff --git a/tests/http/user-agent.php b/tests/http/user-agent.php deleted file mode 100644 index 03d7a2c..0000000 --- a/tests/http/user-agent.php +++ /dev/null @@ -1,10 +0,0 @@ -getBody()->write($_SERVER['HTTP_USER_AGENT']); - return $resp; -} diff --git a/tests/idle.php b/tests/idle.php deleted file mode 100644 index fb5c9df..0000000 --- a/tests/idle.php +++ /dev/null @@ -1,15 +0,0 @@ -waitPayload()){ - sleep(3); - $rr->respond(new \Spiral\RoadRunner\Payload("")); -} diff --git a/tests/issue659.php b/tests/issue659.php deleted file mode 100644 index 2a0e4f1..0000000 --- a/tests/issue659.php +++ /dev/null @@ -1,21 +0,0 @@ -waitRequest()) { - $psr7->getWorker()->error("test_error"); -} diff --git a/tests/memleak.php b/tests/memleak.php deleted file mode 100644 index 96ed500..0000000 --- a/tests/memleak.php +++ /dev/null @@ -1,15 +0,0 @@ -waitPayload()){ - $mem .= str_repeat("a", 1024*1024*10); - $rr->respond(new \Spiral\RoadRunner\Payload("")); -} diff --git a/tests/metrics-issue-571.php b/tests/metrics-issue-571.php deleted file mode 100644 index 947ae1f..0000000 --- a/tests/metrics-issue-571.php +++ /dev/null @@ -1,37 +0,0 @@ -getRPCAddress()) -); - -$metrics->declare( - 'test', - RoadRunner\Metrics\Collector::counter()->withHelp('Test counter') -); - -while ($req = $worker->waitRequest()) { - try { - $rsp = new \Nyholm\Psr7\Response(); - $rsp->getBody()->write("hello world"); - - $metrics->add('test', 1); - - $worker->respond($rsp); - } catch (\Throwable $e) { - $worker->getWorker()->error((string)$e); - } -} diff --git a/tests/pid.php b/tests/pid.php deleted file mode 100644 index 962b609..0000000 --- a/tests/pid.php +++ /dev/null @@ -1,17 +0,0 @@ -waitPayload()) { - try { - $rr->respond(new RoadRunner\Payload((string)getmypid())); - } catch (\Throwable $e) { - $rr->error((string)$e); - } - } diff --git a/tests/pipes_test_script.sh b/tests/pipes_test_script.sh deleted file mode 100755 index c759b0a..0000000 --- a/tests/pipes_test_script.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -php ../../tests/client.php echo pipes diff --git a/tests/psr-worker-bench.php b/tests/psr-worker-bench.php deleted file mode 100644 index 1513211..0000000 --- a/tests/psr-worker-bench.php +++ /dev/null @@ -1,30 +0,0 @@ -waitRequest()) { - try { - $resp = new \Nyholm\Psr7\Response(); - $resp->getBody()->write("hello world"); - - $psr7->respond($resp); - } catch (\Throwable $e) { - $psr7->getWorker()->error((string)$e); - } -} diff --git a/tests/psr-worker-post.php b/tests/psr-worker-post.php deleted file mode 100644 index 2f54af5..0000000 --- a/tests/psr-worker-post.php +++ /dev/null @@ -1,30 +0,0 @@ -waitRequest()) { - try { - $resp = new \Nyholm\Psr7\Response(); - $resp->getBody()->write((string) $req->getBody()); - - $psr7->respond($resp); - } catch (\Throwable $e) { - $psr7->getWorker()->error((string)$e); - } -} diff --git a/tests/psr-worker-slow.php b/tests/psr-worker-slow.php deleted file mode 100644 index 153dff6..0000000 --- a/tests/psr-worker-slow.php +++ /dev/null @@ -1,29 +0,0 @@ -waitRequest()) { - try { - $resp = new \Nyholm\Psr7\Response(); - sleep(mt_rand(1,20)); - $resp->getBody()->write("hello world"); - - $psr7->respond($resp); - } catch (\Throwable $e) { - $psr7->getWorker()->error((string)$e); - } -} diff --git a/tests/psr-worker.php b/tests/psr-worker.php deleted file mode 100644 index db53eee..0000000 --- a/tests/psr-worker.php +++ /dev/null @@ -1,28 +0,0 @@ -waitRequest()) { - try { - $resp = new \Nyholm\Psr7\Response(); - $resp->getBody()->write(str_repeat("hello world", 1000)); - - $psr7->respond($resp); - } catch (\Throwable $e) { - $psr7->getWorker()->error((string)$e); - } -} diff --git a/tests/raw-error.php b/tests/raw-error.php deleted file mode 100644 index 3caf46c..0000000 --- a/tests/raw-error.php +++ /dev/null @@ -1,21 +0,0 @@ -waitRequest()) {} diff --git a/tests/sample.txt b/tests/sample.txt deleted file mode 100644 index d64a3d9..0000000 --- a/tests/sample.txt +++ /dev/null @@ -1 +0,0 @@ -sample diff --git a/tests/should-not-be-killed.php b/tests/should-not-be-killed.php deleted file mode 100644 index ba34927..0000000 --- a/tests/should-not-be-killed.php +++ /dev/null @@ -1,19 +0,0 @@ -waitPayload()){ - // Allocate some memory, this should be enough to exceed the `max_worker_memory` limit defined in - // .rr.yaml - $array = range(1, 10000000); - fprintf(STDERR, 'memory allocated: ' . memory_get_usage()); - sleep(30); - - $rr->respond(new \Spiral\RoadRunner\Payload("alive")); -} diff --git a/tests/sleep-ttl.php b/tests/sleep-ttl.php deleted file mode 100644 index 2230e61..0000000 --- a/tests/sleep-ttl.php +++ /dev/null @@ -1,15 +0,0 @@ -waitPayload()){ - sleep(10); - $rr->respond(new \Spiral\RoadRunner\Payload("hello world")); -} diff --git a/tests/sleep.php b/tests/sleep.php deleted file mode 100644 index d36ae3e..0000000 --- a/tests/sleep.php +++ /dev/null @@ -1,15 +0,0 @@ -waitPayload()){ - sleep(300); - $rr->respond(new \Spiral\RoadRunner\Payload("")); -} diff --git a/tests/sleep_short.php b/tests/sleep_short.php deleted file mode 100644 index 863ac74..0000000 --- a/tests/sleep_short.php +++ /dev/null @@ -1,15 +0,0 @@ -waitPayload()){ - sleep(2); - $rr->respond(new \Spiral\RoadRunner\Payload("")); -} diff --git a/tests/slow-client.php b/tests/slow-client.php deleted file mode 100644 index 565fe50..0000000 --- a/tests/slow-client.php +++ /dev/null @@ -1,38 +0,0 @@ -waitPayload()) { - try { - sleep(1); - $rr->respond(new RoadRunner\Payload((string)getmypid())); - } catch (\Throwable $e) { - $rr->error((string)$e); - } - } diff --git a/tests/slow_req.php b/tests/slow_req.php deleted file mode 100644 index 8fcf3c8..0000000 --- a/tests/slow_req.php +++ /dev/null @@ -1,27 +0,0 @@ -waitPayload()) { - try { - $counter++; - - if ($counter % 2 === 0) { - $rr->error('test error'); - continue; - } - - sleep(100); - $rr->respond(new RoadRunner\Payload((string)$in->body)); - } catch (\Throwable $e) { - $rr->error((string)$e); - } -} diff --git a/tests/socket_test_script.sh b/tests/socket_test_script.sh deleted file mode 100755 index 3948c4f..0000000 --- a/tests/socket_test_script.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -php ../../tests/client.php echo tcp diff --git a/tests/src/Activity/SimpleActivity.php b/tests/src/Activity/SimpleActivity.php deleted file mode 100644 index 576b126..0000000 --- a/tests/src/Activity/SimpleActivity.php +++ /dev/null @@ -1,63 +0,0 @@ -", $user->name, $user->email)); - } - - #[ActivityMethod] - public function slow( - string $input - ): string { - sleep(2); - - return strtolower($input); - } - - #[ActivityMethod] - public function sha512( - Bytes $input - ): string { - return hash("sha512", ($input->getData())); - } - - public function updateRunID(WorkflowExecution $e): WorkflowExecution - { - $e->setRunId('updated'); - return $e; - } - - #[ActivityMethod] - public function fail() - { - throw new \Error("failed activity"); - } -} \ No newline at end of file diff --git a/tests/src/Client/StartNewWorkflow.php b/tests/src/Client/StartNewWorkflow.php deleted file mode 100644 index 67bc1d0..0000000 --- a/tests/src/Client/StartNewWorkflow.php +++ /dev/null @@ -1,23 +0,0 @@ -stub = $client->newWorkflowStub(SimpleDTOWorkflow::class); - } - - public function __invoke() - { - } -} diff --git a/tests/src/Workflow/SagaWorkflow.php b/tests/src/Workflow/SagaWorkflow.php deleted file mode 100644 index e47c020..0000000 --- a/tests/src/Workflow/SagaWorkflow.php +++ /dev/null @@ -1,54 +0,0 @@ -withStartToCloseTimeout(60) - ->withRetryOptions(RetryOptions::new()->withMaximumAttempts(1)) - ); - - $saga = new Workflow\Saga(); - $saga->setParallelCompensation(true); - - try { - yield $simple->echo('test'); - $saga->addCompensation( - function () use ($simple) { - yield $simple->echo('compensate echo'); - } - ); - - yield $simple->lower('TEST'); - $saga->addCompensation( - function () use ($simple) { - yield $simple->lower('COMPENSATE LOWER'); - } - ); - - yield $simple->fail(); - } catch (\Throwable $e) { - yield $saga->compensate(); - throw $e; - } - } -} diff --git a/tests/stop.php b/tests/stop.php deleted file mode 100644 index 9326382..0000000 --- a/tests/stop.php +++ /dev/null @@ -1,25 +0,0 @@ -waitPayload()) { - try { - if ($used) { - // kill on second attempt - $rr->stop(); - continue; - } - - $used = true; - $rr->respond(new RoadRunner\Payload((string)getmypid())); - } catch (\Throwable $e) { - $rr->error((string)$e); - } -} diff --git a/tests/stream_worker.php b/tests/stream_worker.php deleted file mode 100644 index 7540ba6..0000000 --- a/tests/stream_worker.php +++ /dev/null @@ -1,34 +0,0 @@ -chunk_size = 10 * 10 * 1024; -$fp = "fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; - -while ($worker->waitPayload()) { - try { - $resp = (new Response())->withBody(Stream::create($fp)); - $psr7->respond($resp); - } catch (\Throwable $e) { - $psr7->getWorker()->error((string)$e); - } -} - -?> \ No newline at end of file diff --git a/tests/supervised.php b/tests/supervised.php deleted file mode 100644 index 74f8c99..0000000 --- a/tests/supervised.php +++ /dev/null @@ -1,14 +0,0 @@ -waitPayload()){ - $rr->respond(new \Spiral\RoadRunner\Payload("")); -} diff --git a/tests/worker-slow-dyn.php b/tests/worker-slow-dyn.php deleted file mode 100644 index 3f4aa93..0000000 --- a/tests/worker-slow-dyn.php +++ /dev/null @@ -1,31 +0,0 @@ -waitRequest()) { - try { - $resp = new \Nyholm\Psr7\Response(); - sleep(10); - $resp->getBody()->write("hello world"); - - $psr7->respond($resp); - } catch (\Throwable $e) { - $psr7->getWorker()->error((string)$e); - } -} diff --git a/worker/options.go b/worker/options.go index 6b74825..998eb52 100644 --- a/worker/options.go +++ b/worker/options.go @@ -1,13 +1,8 @@ package worker import ( - "crypto/rand" "log/slog" - "math/big" -) - -const ( - maxExecsPercentJitter uint64 = 15 + "time" ) type Options func(p *Process) @@ -18,33 +13,12 @@ func WithLog(z *slog.Logger) Options { } } -func WithMaxExecs(maxExecs uint64) Options { +// WithStopTimeout overrides how long Stop() waits for the process to exit +// after SIGTERM before sending SIGKILL (default 10s). +func WithStopTimeout(d time.Duration) Options { return func(p *Process) { - p.maxExecs = calculateMaxExecsJitter(maxExecs, maxExecsPercentJitter, p.log) - } -} - -func calculateMaxExecsJitter(maxExecs, jitter uint64, log *slog.Logger) uint64 { - if maxExecs == 0 { - return 0 - } - - random, err := rand.Int(rand.Reader, big.NewInt(int64(jitter))) //nolint:gosec - - if err != nil { - if log != nil { - log.Debug("jitter calculation error", "error", err, "jitter", jitter) + if d > 0 { + p.stopTimeout = d } - return maxExecs - } - - percent := random.Uint64() - - if percent == 0 { - return maxExecs } - - result := (float64(maxExecs) * float64(percent)) / 100.0 - - return maxExecs + uint64(result) } diff --git a/worker/worker.go b/worker/worker.go index 6eaf2d5..d6a829e 100644 --- a/worker/worker.go +++ b/worker/worker.go @@ -1,37 +1,31 @@ package worker import ( - "bytes" - "context" stderr "errors" "fmt" - "io" "log/slog" "os" "os/exec" - "runtime" "strconv" "strings" - "sync" + "syscall" "time" "github.com/roadrunner-server/errors" - "github.com/roadrunner-server/goridge/v4/pkg/frame" - "github.com/roadrunner-server/goridge/v4/pkg/relay" "github.com/roadrunner-server/pool/v2/fsm" - "github.com/roadrunner-server/pool/v2/internal" - "github.com/roadrunner-server/pool/v2/payload" ) -// Process - supervised process with api over goridge.Relay. +// defaultStopTimeout is how long Stop() waits for the process to exit after SIGTERM before sending SIGKILL. +const defaultStopTimeout = 10 * time.Second + +// Process - supervised OS process. The process carries no IPC with RoadRunner: +// its stdout/stderr are passed through to the parent process and its lifecycle +// (start, supervision, stop) is the only pool concern. type Process struct { // created indicates at what time Process has been created. created time.Time log *slog.Logger - // calculated maximum value with jitter - maxExecs uint64 - callback func() // fsm holds information about current Process state, // number of Process executions, buf status change time. @@ -40,28 +34,16 @@ type Process struct { fsm *fsm.Fsm // underlying command with associated process, command must be - // provided to Process from outside in non-started form. CmdSource - // stdErr direction will be handled by Process to aggregate error message. + // provided to Process from outside in non-started form. cmd *exec.Cmd // pid of the process, points to pid of underlying process and // can be nil while process is not started. pid int - fPool sync.Pool - bPool sync.Pool - chPool sync.Pool - doneCh chan struct{} - - // communication bus with underlying process. - relay relay.Relay -} - -// internal struct to pass data between goroutines -type wexec struct { - payload *payload.Payload - err error + // stopTimeout is how long Stop() waits for the process to exit after SIGTERM before sending SIGKILL. + stopTimeout time.Duration } // InitBaseWorker creates new Process over given exec.cmd. @@ -71,25 +53,10 @@ func InitBaseWorker(cmd *exec.Cmd, options ...Options) (*Process, error) { } w := &Process{ - created: time.Now(), - cmd: cmd, - doneCh: make(chan struct{}, 1), - - fPool: sync.Pool{ - New: func() any { - return frame.NewFrame() - }, - }, - bPool: sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, - }, - chPool: sync.Pool{ - New: func() any { - return make(chan *wexec, 1) - }, - }, + created: time.Now(), + cmd: cmd, + doneCh: make(chan struct{}, 1), + stopTimeout: defaultStopTimeout, } // add options @@ -103,21 +70,15 @@ func InitBaseWorker(cmd *exec.Cmd, options ...Options) (*Process, error) { w.fsm = fsm.NewFSM(fsm.StateInactive, w.log) - // set self as stderr implementation (Writer interface) - rc, err := cmd.StderrPipe() - if err != nil { - return nil, err + // pass the worker output through to the parent process unless the embedder + // pre-set its own writers; an *os.File is inherited by the child directly, + // so there is no copy goroutine and no logging layer in between + if cmd.Stdout == nil { + cmd.Stdout = os.Stdout + } + if cmd.Stderr == nil { + cmd.Stderr = os.Stderr } - - go func() { - // https://linux.die.net/man/7/pipe - // see pipe capacity - buf := make([]byte, 65536) - errCopy := copyBuffer(w, rc, buf) - if errCopy != nil { - w.log.Debug("stderr", "error", errCopy) - } - }() return w, nil } @@ -149,16 +110,6 @@ func (w *Process) State() *fsm.Fsm { return w.fsm } -// AttachRelay attaches relay to the worker -func (w *Process) AttachRelay(rl relay.Relay) { - w.relay = rl -} - -// Relay returns relay attached to the worker -func (w *Process) Relay() relay.Relay { - return w.relay -} - // String returns Process description. fmt.Stringer interface func (w *Process) String() string { st := w.fsm.String() @@ -175,6 +126,7 @@ func (w *Process) String() string { ) } +// Start starts underlying process. func (w *Process) Start() error { err := w.cmd.Start() if err != nil { @@ -185,13 +137,11 @@ func (w *Process) Start() error { } // Wait must be called once for each Process, call will be released once Process is -// complete and will return process error (if any), if stderr is presented it is value -// will be wrapped as WorkerError. Method will return error code if php process fails -// to find or Start the script. +// complete and will return process error (if any). Method will return error code +// if the process fails to find or Start the script. func (w *Process) Wait() error { const op = errors.Op("process_wait") - var err error - err = w.cmd.Wait() + err := w.cmd.Wait() w.doneCh <- struct{}{} // If worker was destroyed, just exit @@ -205,16 +155,6 @@ func (w *Process) Wait() error { err = stderr.Join(err, errors.E(op, err)) } - // closeRelay - // at this point according to the documentation (see cmd.Wait comment) - // if worker finishes with an error, message will be written to the stderr first - // and then process.cmd.Wait return an error - err2 := w.closeRelay() - if err2 != nil { - w.State().Transition(fsm.StateErrored) - return stderr.Join(err, errors.E(op, err2)) - } - if w.cmd.ProcessState.Success() { w.State().Transition(fsm.StateStopped) } @@ -222,239 +162,35 @@ func (w *Process) Wait() error { return err } -func (w *Process) StreamIter() (*payload.Payload, bool, error) { - pld, err := w.receiveFrame() - if err != nil { - return nil, false, err - } - - // PING, we should respond with PONG - if pld.Flags&frame.PING != 0 { - if err := w.sendPONG(); err != nil { - return nil, false, err - } - } - - // !=0 -> we have stream bit set, so stream is available - return pld, pld.Flags&frame.STREAM != 0, nil -} - -// StreamIter returns true if stream is available and payload -func (w *Process) StreamIterWithContext(ctx context.Context) (*payload.Payload, bool, error) { - c := w.getCh() - - go func() { - rsp, err := w.receiveFrame() - if err != nil { - c <- &wexec{ - err: err, - } - - w.log.Debug("stream iter error", "pid", w.Pid(), "error", err) - // trash response - rsp = nil - runtime.Goexit() - } - - c <- &wexec{ - payload: rsp, - } - }() - - select { - // exec TTL reached - case <-ctx.Done(): - // we should kill the process here to ensure that it exited - errK := w.Kill() - err := stderr.Join(errK, ctx.Err()) - // we should wait for the exit from the worker - // 'c' channel here should return an error or nil - // because the goroutine holds the payload pointer (from the sync.Pool) - <-c - w.putCh(c) - return nil, false, errors.E(errors.ExecTTL, err) - case res := <-c: - w.putCh(c) - - if res.err != nil { - return nil, false, res.err - } - - // PING, we should respond with PONG - if res.payload.Flags&frame.PING != 0 { - if err := w.sendPONG(); err != nil { - return nil, false, err - } - } - - // pld.Flags&frame.STREAM !=0 -> we have stream bit set, so stream is available - return res.payload, res.payload.Flags&frame.STREAM != 0, nil - } -} - -// StreamCancel sends stop bit to the worker -func (w *Process) StreamCancel(ctx context.Context) error { - const op = errors.Op("sync_worker_stream_cancel") - if !w.State().Compare(fsm.StateWorking) { - return errors.Errorf("worker is not in the Working state, actual state: (%s)", w.State().String()) - } - - w.log.Debug("stream was canceled, sending stop bit", "pid", w.Pid()) - // get a frame - fr := w.getFrame() - - fr.WriteVersion(fr.Header(), frame.Version1) - - fr.SetStopBit(fr.Header()) - fr.WriteCRC(fr.Header()) - - err := w.Relay().Send(fr) - w.State().RegisterExec() - if err != nil { - w.putFrame(fr) - return errors.E(op, errors.Network, err) - } - - w.putFrame(fr) - c := w.getCh() - - w.log.Debug("stop bit was sent, waiting for the response", "pid", w.Pid()) - - go func() { - for { - rsp, errrf := w.receiveFrame() - if errrf != nil { - c <- &wexec{ - err: errrf, - } - - w.log.Debug("stream cancel error", "pid", w.Pid(), "error", errrf) - // trash response - rsp = nil - runtime.Goexit() - } - - // stream has ended - if rsp.Flags&frame.STREAM == 0 { - w.log.Debug("stream has ended", "pid", w.Pid()) - c <- &wexec{} - // trash response - rsp = nil - runtime.Goexit() - } - } - }() - - select { - // exec TTL reached - case <-ctx.Done(): - errK := w.Kill() - err := stderr.Join(errK, ctx.Err()) - // we should wait for the exit from the worker - // 'c' channel here should return an error or nil - // because the goroutine holds the payload pointer (from the sync.Pool) - <-c - w.putCh(c) - return errors.E(op, errors.ExecTTL, err) - case res := <-c: - w.putCh(c) - if res.err != nil { - return res.err - } - return nil - } -} - -// Exec executes payload with TTL timeout in the context. -func (w *Process) Exec(ctx context.Context, p *payload.Payload) (*payload.Payload, error) { - const op = errors.Op("worker_exec_with_timeout") - - // worker was killed before it started to work (supervisor) - if !w.State().Compare(fsm.StateReady) { - return nil, errors.E(op, errors.Retry, errors.Errorf("Process is not ready (%s)", w.State().String())) - } - - c := w.getCh() - // set last used time - w.State().SetLastUsed(uint64(time.Now().UnixNano())) - w.State().Transition(fsm.StateWorking) - - go func() { - err := w.sendFrame(p) - if err != nil { - c <- &wexec{ - err: err, - } - runtime.Goexit() - } - - w.State().RegisterExec() - rsp, err := w.receiveFrame() - if err != nil { - c <- &wexec{ - payload: rsp, - err: err, - } - - runtime.Goexit() - } - - c <- &wexec{ - payload: rsp, - } - }() - - select { - // exec TTL reached - case <-ctx.Done(): - errK := w.Kill() - err := stderr.Join(errK, ctx.Err()) - // we should wait for the exit from the worker - // 'c' channel here should return an error or nil - // because the goroutine holds the payload pointer (from the sync.Pool) - <-c - w.putCh(c) - return nil, errors.E(op, errors.ExecTTL, err) - case res := <-c: - w.putCh(c) - if res.err != nil { - return nil, res.err - } - return res.payload, nil - } -} - -// Stop sends soft termination command to the Process and waits for process completion. +// Stop gracefully terminates the worker: sends SIGTERM, waits for the process exit +// (rendezvous with Wait() via doneCh) and sends SIGKILL after the stop timeout. +// On platforms without SIGTERM delivery (Windows) or if the process is already +// gone, it degrades to SIGKILL immediately and still waits for the rendezvous. func (w *Process) Stop() error { const op = errors.Op("process_stop") w.fsm.Transition(fsm.StateStopping) - go func() { - w.log.Debug("sending stop request to the worker", "pid", w.pid) - err := internal.SendControl(w.relay, &internal.StopCommand{Stop: true}) - if err == nil { - w.fsm.Transition(fsm.StateStopped) - } - }() + w.log.Debug("sending SIGTERM to the worker", "pid", w.pid) + if err := w.cmd.Process.Signal(syscall.SIGTERM); err != nil { + // process already exited, or platform doesn't support SIGTERM (Windows) + _ = w.cmd.Process.Kill() + } select { - // finished, sent to the doneCh is made in the Wait() method - // If we successfully sent a stop request, Wait() method will send a struct{} to the doneCh and we're done here - // otherwise we have 10 seconds before we kill the process + // the doneCh send is made in the Wait() method after the process exits case <-w.doneCh: + w.fsm.Transition(fsm.StateStopped) w.log.Debug("worker stopped", "pid", w.pid) return nil - case <-time.After(time.Second * 10): - // kill process - w.log.Warn("worker doesn't respond on stop command, killing process", "pid", w.Pid()) - _ = w.cmd.Process.Signal(os.Kill) + case <-time.After(w.stopTimeout): + w.log.Warn("worker did not exit after SIGTERM, killing it", "pid", w.pid) + _ = w.cmd.Process.Kill() w.fsm.Transition(fsm.StateStopped) - return errors.E(op, errors.Network) + return errors.E(op, errors.Str("worker did not stop within the stop timeout and was killed")) } } -// Kill kills underlying process, make sure to call Wait() func to gather -// error log from the stderr. Does not wait for process completion! +// Kill kills underlying process. Does not wait for process completion! func (w *Process) Kill() error { w.fsm.Transition(fsm.StateStopping) err := w.cmd.Process.Kill() @@ -464,206 +200,3 @@ func (w *Process) Kill() error { w.fsm.Transition(fsm.StateStopped) return nil } - -// Worker stderr -func (w *Process) Write(p []byte) (int, error) { - // unsafe to use utils.AsString - w.log.Info(string(p)) - return len(p), nil -} - -func (w *Process) MaxExecsReached() bool { - return w.maxExecs > 0 && w.State().NumExecs() >= w.maxExecs -} - -func (w *Process) MaxExecs() uint64 { - return w.maxExecs -} - -// copyBuffer is the actual implementation of Copy and CopyBuffer. -func copyBuffer(dst io.Writer, src io.Reader, buf []byte) error { - for { - nr, er := src.Read(buf) - if nr > 0 { - nw, ew := dst.Write(buf[0:nr]) - if nw < 0 || nr < nw { - nw = 0 - if ew == nil { - ew = errors.Str("invalid write result") - } - } - if ew != nil { - return ew - } - if nr != nw { - return io.ErrShortWrite - } - } - if er != nil { - if er != io.EOF { - return er - } - break - } - } - - return nil -} - -// sendFrame sends frame to the worker -func (w *Process) sendFrame(p *payload.Payload) error { - const op = errors.Op("sync_worker_send_frame") - // get a frame - fr := w.getFrame() - buf := w.get() - - // can be 0 here - fr.WriteVersion(fr.Header(), frame.Version1) - fr.WriteFlags(fr.Header(), p.Codec) - - // obtain a buffer - buf.Write(p.Context) - buf.Write(p.Body) - - // Context offset - fr.WriteOptions(fr.HeaderPtr(), uint32(len(p.Context))) //nolint:gosec - fr.WritePayloadLen(fr.Header(), uint32(buf.Len())) //nolint:gosec - fr.WritePayload(buf.Bytes()) - - fr.WriteCRC(fr.Header()) - - // return buffer - w.put(buf) - - err := w.Relay().Send(fr) - if err != nil { - w.putFrame(fr) - return errors.E(op, errors.Network, err) - } - w.putFrame(fr) - return nil -} - -func (w *Process) receiveFrame() (*payload.Payload, error) { - const op = errors.Op("sync_worker_receive_frame") - - frameR := w.getFrame() - - err := w.Relay().Receive(frameR) - if err != nil { - w.putFrame(frameR) - return nil, errors.E(op, errors.Network, err) - } - - if frameR == nil { - w.putFrame(frameR) - return nil, errors.E(op, errors.Network, errors.Str("nil frame received")) - } - - codec := frameR.ReadFlags() - - if codec&frame.ERROR != byte(0) { - // we need to copy the payload because we will put the frame back to the pool - cp := make([]byte, len(frameR.Payload())) - copy(cp, frameR.Payload()) - - w.putFrame(frameR) - return nil, errors.E(op, errors.SoftJob, errors.Str(string(cp))) - } - - options := frameR.ReadOptions(frameR.Header()) - if len(options) != 1 { - w.putFrame(frameR) - return nil, errors.E(op, errors.Decode, errors.Str("options length should be equal 1 (body offset)")) - } - - // bound check - if len(frameR.Payload()) < int(options[0]) { - // we need to copy the payload because we will put the frame back to the pool - cp := make([]byte, len(frameR.Payload())) - copy(cp, frameR.Payload()) - - w.putFrame(frameR) - return nil, errors.E(errors.Network, errors.Errorf("bad payload %s", cp)) - } - - // stream + stop -> waste - // stream + ping -> response - flags := frameR.Header()[10] - pld := &payload.Payload{ - Flags: flags, - Codec: codec, - Body: make([]byte, len(frameR.Payload()[options[0]:])), - Context: make([]byte, len(frameR.Payload()[:options[0]])), - } - - // by copying we free frame's payload slice - // we do not hold the pointer from the smaller slice to the initial (which should be in the sync.Pool) - // https://blog.golang.org/slices-intro#TOC_6. - copy(pld.Body, frameR.Payload()[options[0]:]) - copy(pld.Context, frameR.Payload()[:options[0]]) - - w.putFrame(frameR) - return pld, nil -} - -func (w *Process) sendPONG() error { - // get a frame - fr := w.getFrame() - fr.WriteVersion(fr.Header(), frame.Version1) - - fr.SetPongBit(fr.Header()) - fr.WriteCRC(fr.Header()) - - err := w.Relay().Send(fr) - w.State().RegisterExec() - if err != nil { - w.putFrame(fr) - w.State().Transition(fsm.StateErrored) - return errors.E(errors.Network, err) - } - - w.putFrame(fr) - return nil -} - -func (w *Process) closeRelay() error { - if w.relay != nil { - err := w.relay.Close() - if err != nil { - return err - } - } - return nil -} - -func (w *Process) get() *bytes.Buffer { - return w.bPool.Get().(*bytes.Buffer) -} - -func (w *Process) put(b *bytes.Buffer) { - b.Reset() - w.bPool.Put(b) -} - -func (w *Process) getFrame() *frame.Frame { - return w.fPool.Get().(*frame.Frame) -} - -func (w *Process) putFrame(f *frame.Frame) { - f.Reset() - w.fPool.Put(f) -} - -func (w *Process) getCh() chan *wexec { - return w.chPool.Get().(chan *wexec) -} - -func (w *Process) putCh(ch chan *wexec) { - // just check if the chan is not empty - select { - case <-ch: - default: - } - w.chPool.Put(ch) -} diff --git a/worker/worker_test.go b/worker/worker_test.go index 4d7be03..4428de4 100644 --- a/worker/worker_test.go +++ b/worker/worker_test.go @@ -1,44 +1,161 @@ package worker import ( + "bytes" + "os" "os/exec" "testing" + "time" - "github.com/roadrunner-server/pool/v2/payload" + "github.com/roadrunner-server/pool/v2/fsm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_OnStarted(t *testing.T) { - cmd := exec.Command("php", "tests/client.php", "broken", "pipes") - assert.Nil(t, cmd.Start()) - - w, err := InitBaseWorker(cmd) - assert.Nil(t, w) - assert.NotNil(t, err) + cmd := exec.Command("sleep", "300") + require.NoError(t, cmd.Start()) + defer func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + _, err := InitBaseWorker(cmd) + require.Error(t, err) assert.Equal(t, "can't attach to running process", err.Error()) } -func Test_NotStarted_String(t *testing.T) { - cmd := exec.Command("php", "tests/client.php", "echo", "pipes") +func Test_String(t *testing.T) { + w, err := InitBaseWorker(exec.Command("sleep", "300")) + require.NoError(t, err) - w, _ := InitBaseWorker(cmd) - assert.Contains(t, w.String(), "php tests/client.php echo pipes") + assert.Contains(t, w.String(), "sleep 300") assert.Contains(t, w.String(), "inactive") assert.Contains(t, w.String(), "num_execs: 0") } -func Test_NotStarted_Exec(t *testing.T) { - cmd := exec.Command("php", "tests/client.php", "echo", "pipes") +func Test_Stdio_DefaultInherit(t *testing.T) { + cmd := exec.Command("sleep", "300") + _, err := InitBaseWorker(cmd) + require.NoError(t, err) + + // child stdio is passed through to the parent process by default + assert.Equal(t, os.Stdout, cmd.Stdout) + assert.Equal(t, os.Stderr, cmd.Stderr) +} + +func Test_Stdio_RespectsPreset(t *testing.T) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + + cmd := exec.Command("sh", "-c", "echo out; echo err 1>&2") + cmd.Stdout = stdout + cmd.Stderr = stderr w, err := InitBaseWorker(cmd) require.NoError(t, err) + require.NoError(t, w.Start()) + _ = w.Wait() + + assert.Equal(t, "out\n", stdout.String()) + assert.Equal(t, "err\n", stderr.String()) + assert.True(t, w.State().Compare(fsm.StateStopped)) +} + +func Test_Stop_Graceful(t *testing.T) { + // the sleep must be backgrounded: shell traps don't fire until the foreground + // command exits. The trap also kills the sleep so no orphan outlives the shell. + w, err := InitBaseWorker(exec.Command("sh", "-c", `trap 'kill $!; exit 0' TERM; sleep 300 & wait $!`)) + require.NoError(t, err) + require.NoError(t, w.Start()) + + go func() { + _ = w.Wait() + }() + + // let the shell install the trap + time.Sleep(time.Millisecond * 300) + + start := time.Now() + err = w.Stop() + require.NoError(t, err) + assert.Less(t, time.Since(start), 5*time.Second) + assert.True(t, w.State().Compare(fsm.StateStopped)) +} + +func Test_Stop_KillFallback(t *testing.T) { + // the ignored-TERM disposition survives exec, so the single resulting sleep + // process ignores SIGTERM -> only the SIGKILL fallback stops it + w, err := InitBaseWorker( + exec.Command("sh", "-c", `trap '' TERM; exec sleep 300`), + WithStopTimeout(time.Millisecond*200), + ) + require.NoError(t, err) + require.NoError(t, w.Start()) + + waitErr := make(chan error, 1) + go func() { + waitErr <- w.Wait() + }() + + // let the shell install the trap + time.Sleep(time.Millisecond * 300) + + err = w.Stop() + require.Error(t, err) + assert.True(t, w.State().Compare(fsm.StateStopped)) + + select { + case werr := <-waitErr: + // the process was SIGKILLed + require.Error(t, werr) + case <-time.After(5 * time.Second): + t.Fatal("worker was not killed after the stop timeout") + } +} + +func Test_Stop_AlreadyDead(t *testing.T) { + w, err := InitBaseWorker(exec.Command("sh", "-c", "true")) + require.NoError(t, err) + require.NoError(t, w.Start()) + require.NoError(t, w.Wait()) + + // the doneCh token is already buffered, Stop returns immediately + start := time.Now() + require.NoError(t, w.Stop()) + assert.Less(t, time.Since(start), time.Second) +} + +func Test_Wait_States(t *testing.T) { + w, err := InitBaseWorker(exec.Command("sh", "-c", "exit 0")) + require.NoError(t, err) + require.NoError(t, w.Start()) + require.NoError(t, w.Wait()) + assert.True(t, w.State().Compare(fsm.StateStopped)) + + w, err = InitBaseWorker(exec.Command("sh", "-c", "exit 1")) + require.NoError(t, err) + require.NoError(t, w.Start()) + require.Error(t, w.Wait()) + assert.True(t, w.State().Compare(fsm.StateErrored)) +} + +func Test_Kill(t *testing.T) { + w, err := InitBaseWorker(exec.Command("sleep", "300")) + require.NoError(t, err) + require.NoError(t, w.Start()) + + waitErr := make(chan error, 1) + go func() { + waitErr <- w.Wait() + }() - _, err = w.Exec(t.Context(), &payload.Payload{ - Body: []byte("hello"), - }) + require.NoError(t, w.Kill()) - assert.Error(t, err) - assert.Contains(t, err.Error(), "Process is not ready (inactive)") + select { + case werr := <-waitErr: + require.Error(t, werr) + case <-time.After(5 * time.Second): + t.Fatal("worker was not killed") + } } diff --git a/worker_watcher/container/channel/vec_test.go b/worker_watcher/container/channel/vec_test.go index bde4d5b..54b8444 100644 --- a/worker_watcher/container/channel/vec_test.go +++ b/worker_watcher/container/channel/vec_test.go @@ -17,7 +17,7 @@ import ( // helper to create a simple worker for testing func createTestWorker(t *testing.T) *worker.Process { t.Helper() - cmd := exec.Command("php", "-v") + cmd := exec.Command("sleep", "100") w, err := worker.InitBaseWorker(cmd) require.NoError(t, err) return w diff --git a/worker_watcher/worker_watcher_test.go b/worker_watcher/worker_watcher_test.go index c63e984..f117974 100644 --- a/worker_watcher/worker_watcher_test.go +++ b/worker_watcher/worker_watcher_test.go @@ -26,8 +26,7 @@ func alwaysFailAllocator() Allocator { // fakeAllocator returns an allocator that fails failCount times then succeeds. // Counter semantics: Store(2) means "fail twice" because Add(-1) returns the new value, // so call 1 returns 1 (>=0, fail), call 2 returns 0 (>=0, fail), call 3 returns -1 (<0, succeed). -// Workers are created with a running "sleep" process so Kill won't panic. -// Note: workers won't have a relay, so Stop() will panic — tests must avoid Stop paths. +// Workers are created with a running "sleep" process so Kill() and Stop() operate on a real pid. func fakeAllocator(t *testing.T, failCount *atomic.Int32) Allocator { t.Helper() return func() (*worker.Process, error) { @@ -43,6 +42,11 @@ func fakeAllocator(t *testing.T, failCount *atomic.Int32) Allocator { return nil, err } t.Cleanup(func() { + // mark Destroyed first: a watched worker would otherwise be respawned + // by the wait() goroutine after the kill, leaking a process. + // No cmd.Wait() here: the watcher's wait() goroutine owns the reaping + // and exec.Cmd.Wait is not safe to call concurrently. + w.State().Transition(fsm.StateDestroyed) if cmd.Process != nil { _ = cmd.Process.Kill() } @@ -53,14 +57,17 @@ func fakeAllocator(t *testing.T, failCount *atomic.Int32) Allocator { } // createStartedWorker creates a worker with a running process in the specified state. -// Note: no relay attached — Kill() works but Stop() will panic. func createStartedWorker(t *testing.T, state int64) *worker.Process { t.Helper() cmd := exec.Command("sleep", "100") w, err := worker.InitBaseWorker(cmd) require.NoError(t, err) require.NoError(t, cmd.Start()) - t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) + t.Cleanup(func() { + w.State().Transition(fsm.StateDestroyed) + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) switch state { case fsm.StateReady: @@ -77,7 +84,7 @@ func createStartedWorker(t *testing.T, state int64) *worker.Process { return w } -// shutdownWatcher signals the watcher to stop (without calling Destroy which needs relay). +// shutdownWatcher signals the watcher to stop (without the full Destroy rendezvous). func shutdownWatcher(ww *WorkerWatcher) { ww.container.Destroy() select { @@ -175,8 +182,8 @@ func TestWorkerWatcher_Take_FastPath_ReadyWorker(t *testing.T) { } // TestWorkerWatcher_Take_SlowPath_SkipsBadWorkers verifies Take skips non-Ready workers -// and kills them. The first non-Ready worker triggers the slow path via Kill() (works without relay). -// Subsequent bad workers are killed via Stop() — we avoid that by only having one bad + one good. +// and kills them. The first non-Ready worker triggers the slow path via Kill(), +// subsequent bad workers are stopped via Stop(). func TestWorkerWatcher_Take_SlowPath_SkipsBadWorkers(t *testing.T) { log := slog.Default() ww := NewSyncWorkerWatcher(alwaysFailAllocator(), log, 2, 0) @@ -214,6 +221,39 @@ func TestWorkerWatcher_Release_ReadyState_PushesToContainer(t *testing.T) { assert.Equal(t, initialLen+1, ww.container.Len(), "ready worker should be pushed to container") } +// TestWorkerWatcher_Release_BadState_StopsWorker verifies releasing a non-Ready worker +// gracefully stops it (SIGTERM, no relay involved). +func TestWorkerWatcher_Release_BadState_StopsWorker(t *testing.T) { + log := slog.Default() + ww := NewSyncWorkerWatcher(alwaysFailAllocator(), log, 1, 0) + t.Cleanup(func() { shutdownWatcher(ww) }) + + // built inline (not via createStartedWorker): this test runs its own Wait + // goroutine, which must be the only cmd.Wait caller + cmd := exec.Command("sleep", "100") + w, err := worker.InitBaseWorker(cmd) + require.NoError(t, err) + require.NoError(t, w.Start()) + t.Cleanup(func() { _ = cmd.Process.Kill() }) + w.State().Transition(fsm.StateReady) + w.State().Transition(fsm.StateTTLReached) + + waitErr := make(chan error, 1) + go func() { + waitErr <- w.Wait() + }() + + ww.Release(w) + + select { + case <-waitErr: + // the process exited on SIGTERM + assert.Equal(t, fsm.StateStopped, w.State().CurrentState()) + case <-time.After(5 * time.Second): + t.Fatal("worker was not stopped on release") + } +} + // TestWorkerWatcher_Take_AfterDestroy verifies Take returns WatcherStopped after container is destroyed. func TestWorkerWatcher_Take_AfterDestroy(t *testing.T) { log := slog.Default()