Skip to content

Commit 86bd9af

Browse files
feat: declared background workers + frankenphp_get_worker_handle()
Background workers run a script in a loop outside the HTTP request cycle, sharing the PHP runtime with the request threads. Rebuilt on Server from #2499: a background worker attaches to a php_server through WithWorkerServerScope() like any other worker. Declared with "background" in a worker block (php_server or global) or WithWorkerBackground() in Go. name is required, match is rejected, num >= 1. The lifecycle mirrors HTTP workers: re-run on a cooperative exit, restart with quadratic backoff on a crash, max_consecutive_failures fails Init() during startup only. drain() runs on shutdown, reboot and handler transitions so a parked script wakes up instead of waiting out the force-kill grace period. Every worker sees its declared name in $_SERVER['FRANKENPHP_WORKER'], HTTP workers included: the documented contract is to test its presence, not its value. Background workers also get $_SERVER['FRANKENPHP_WORKER_BACKGROUND'], so a script serving both roles can tell them apart with isset(). The script gets one handle, frankenphp_get_worker_handle(), a stream that reaches EOF when the worker is drained, meant to carry control messages later. It is backed by a socket pair, not a pipe: on Windows PHP's php_select() only waits properly on sockets before 8.5. Streams do not own the socket (php_sockop_close() would shutdown() it on Windows), so every call returns a fresh stream and closing one never affects another; the read timeout is infinite so a blocking read parks as well as stream_select() does. Both ends are non-inheritable. A worker counts as ready on its first wait on the handle (select cast or read), the background analog of frankenphp_handle_request(): Init() waits for it, ready_workers counts from it, and an exit before it is a boot failure. The handle's stream ops, copied from the socket ops at MINIT, report it once per run. Worker names are scoped like paths: unique within a php_server or among global workers. The script sees the declared name; metrics and logs report a scoped worker as "<server name>:<name>", with a numeric suffix on server names when two blocks resolve to the same one. The collision-driven renaming in the Caddy module is gone, and WithWorkerName() resolves within the request's server first. Supersedes #2543 and #2398.
1 parent 2e33427 commit 86bd9af

43 files changed

Lines changed: 1648 additions & 186 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bgworker_test.go

Lines changed: 457 additions & 0 deletions
Large diffs are not rendered by default.

caddy/app.go

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
1818
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
1919
"github.com/dunglas/frankenphp"
20-
"github.com/dunglas/frankenphp/internal/fastabs"
2120
)
2221

2322
var (
@@ -60,15 +59,14 @@ type FrankenPHPApp struct {
6059
// EXPERIMENTAL: MaxRequests sets the maximum number of requests a PHP thread handles before restarting (0 = unlimited)
6160
MaxRequests int `json:"max_requests,omitempty"`
6261

63-
opts []frankenphp.Option
64-
metrics frankenphp.Metrics
65-
ctx context.Context
66-
logger *slog.Logger
67-
modules []*FrankenPHPModule
68-
usedWorkerNames map[string]bool
69-
httpApp *caddyhttp.App
70-
hasStarted atomic.Bool
71-
started chan any
62+
opts []frankenphp.Option
63+
metrics frankenphp.Metrics
64+
ctx context.Context
65+
logger *slog.Logger
66+
modules []*FrankenPHPModule
67+
httpApp *caddyhttp.App
68+
hasStarted atomic.Bool
69+
started chan any
7270
}
7371

7472
var errIni = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
@@ -133,7 +131,6 @@ func (f *FrankenPHPApp) Start() error {
133131
// register global workers
134132
for _, w := range f.Workers {
135133
w.FileName = repl.ReplaceKnown(w.FileName, "")
136-
w.Name = f.createUniqueWorkerName(w, "")
137134
opts, err := w.toWorkerOptions()
138135
if err != nil {
139136
return err
@@ -224,7 +221,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM
224221

225222
for _, w := range module.Workers {
226223
w.FileName = repl.ReplaceKnown(w.FileName, "")
227-
w.Name = f.createUniqueWorkerName(w, serverName)
228224
workerOptions, err := w.toWorkerOptions()
229225
if err != nil {
230226
return err
@@ -236,37 +232,6 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM
236232
return nil
237233
}
238234

239-
// avoid name collisions for workers
240-
// on collision, a name is first qualified with the server name
241-
// ("<serverName>:<name>") before falling back to a numeric postfix
242-
func (f *FrankenPHPApp) createUniqueWorkerName(wc workerConfig, serverName string) string {
243-
if f.usedWorkerNames == nil {
244-
f.usedWorkerNames = make(map[string]bool)
245-
}
246-
247-
if wc.Name == "" {
248-
wc.Name, _ = fastabs.FastAbs(wc.FileName)
249-
}
250-
251-
name := wc.Name
252-
suffix := 0
253-
for {
254-
if _, ok := f.usedWorkerNames[name]; !ok {
255-
f.usedWorkerNames[name] = true
256-
break
257-
}
258-
if serverName != "" {
259-
name = serverName + ":" + wc.Name
260-
serverName = ""
261-
continue
262-
}
263-
suffix++
264-
name = fmt.Sprintf("%s_%d", wc.Name, suffix)
265-
}
266-
267-
return name
268-
}
269-
270235
// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
271236
func (f *FrankenPHPApp) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
272237
for d.Next() {

caddy/caddy_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ func TestWorkerMetrics(t *testing.T) {
839839
# TYPE frankenphp_worker_request_count counter
840840
frankenphp_worker_request_count{worker="` + workerName + `"} 10
841841
842-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
842+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
843843
# TYPE frankenphp_ready_workers gauge
844844
frankenphp_ready_workers{worker="` + workerName + `"} 2
845845
`
@@ -996,7 +996,7 @@ func TestNamedWorkerMetrics(t *testing.T) {
996996
# TYPE frankenphp_worker_request_count counter
997997
frankenphp_worker_request_count{worker="my_app"} 10
998998
999-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
999+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
10001000
# TYPE frankenphp_ready_workers gauge
10011001
frankenphp_ready_workers{worker="my_app"} 2
10021002
`
@@ -1092,7 +1092,7 @@ func TestAutoWorkerConfig(t *testing.T) {
10921092
# TYPE frankenphp_worker_request_count counter
10931093
frankenphp_worker_request_count{worker="` + workerName + `"} 10
10941094
1095-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1095+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
10961096
# TYPE frankenphp_ready_workers gauge
10971097
frankenphp_ready_workers{worker="` + workerName + `"} ` + workers + `
10981098
`
@@ -1460,7 +1460,7 @@ func TestMultiWorkersMetrics(t *testing.T) {
14601460
# TYPE frankenphp_worker_request_count counter
14611461
frankenphp_worker_request_count{worker="service1"} 10
14621462
1463-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1463+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
14641464
# TYPE frankenphp_ready_workers gauge
14651465
frankenphp_ready_workers{worker="service1"} 2
14661466
frankenphp_ready_workers{worker="service2"} 3
@@ -1614,7 +1614,7 @@ func TestWorkerRestart(t *testing.T) {
16141614

16151615
// Check metrics
16161616
expectedMetrics := `
1617-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1617+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
16181618
# TYPE frankenphp_ready_workers gauge
16191619
frankenphp_ready_workers{worker="service"} 1
16201620
# HELP frankenphp_total_workers Total number of PHP workers for this worker
@@ -1642,7 +1642,7 @@ func TestWorkerRestart(t *testing.T) {
16421642

16431643
// frankenphp_ready_workers should be back to 1 even after worker restarts
16441644
expectedMetrics = `
1645-
# HELP frankenphp_ready_workers Running workers that have successfully called frankenphp_handle_request at least once
1645+
# HELP frankenphp_ready_workers Running workers that have reached their ready point at least once: frankenphp_handle_request for HTTP workers, frankenphp_get_worker_handle for background workers
16461646
# TYPE frankenphp_ready_workers gauge
16471647
frankenphp_ready_workers{worker="service"} 1
16481648
# HELP frankenphp_total_workers Total number of PHP workers for this worker

caddy/config_test.go

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package caddy
22

33
import (
4-
"path/filepath"
54
"testing"
65
"time"
76

@@ -249,38 +248,73 @@ func TestModuleWorkerWithCustomName(t *testing.T) {
249248
require.Equal(t, "../testdata/worker-with-env.php", module.Workers[0].FileName, "Worker should have the correct filename")
250249
}
251250

252-
func TestCreateUniqueWorkerNames(t *testing.T) {
253-
app := &FrankenPHPApp{}
254-
filename := "../testdata/worker-with-env.php"
255-
absFileName, _ := filepath.Abs(filename)
256-
names := make([]string, 6)
257-
for i := range 3 {
258-
names[i] = app.createUniqueWorkerName(workerConfig{
259-
FileName: filename,
260-
Name: "custom-worker-name",
261-
}, "")
262-
names[i+3] = app.createUniqueWorkerName(workerConfig{
263-
FileName: filename,
264-
}, "")
265-
}
266-
267-
require.Equal(t, "custom-worker-name", names[0])
268-
require.Equal(t, "custom-worker-name_1", names[1])
269-
require.Equal(t, "custom-worker-name_2", names[2])
270-
require.Equal(t, absFileName, names[3])
271-
require.Equal(t, absFileName+"_1", names[4])
272-
require.Equal(t, absFileName+"_2", names[5])
251+
func TestWorkerBackgroundConfig(t *testing.T) {
252+
d := caddyfile.NewTestDispenser(`
253+
{
254+
php_server {
255+
worker {
256+
name jobs
257+
file ../testdata/worker-with-env.php
258+
num 2
259+
background
260+
}
261+
}
262+
}`)
263+
module := &FrankenPHPModule{}
264+
265+
require.NoError(t, module.UnmarshalCaddyfile(d))
266+
require.Len(t, module.Workers, 1)
267+
require.True(t, module.Workers[0].Background)
268+
require.Equal(t, "jobs", module.Workers[0].Name)
269+
}
270+
271+
func TestWorkerBackgroundRequiresName(t *testing.T) {
272+
d := caddyfile.NewTestDispenser(`
273+
{
274+
php_server {
275+
worker {
276+
file ../testdata/worker-with-env.php
277+
background
278+
}
279+
}
280+
}`)
281+
module := &FrankenPHPModule{}
282+
283+
err := module.UnmarshalCaddyfile(d)
284+
require.ErrorContains(t, err, `background workers must have an explicit "name"`)
273285
}
274286

275-
func TestCreateUniqueWorkerNamesQualifiedByServer(t *testing.T) {
276-
app := &FrankenPHPApp{}
277-
wc := workerConfig{FileName: "../testdata/worker-with-env.php", Name: "queue"}
278-
279-
require.Equal(t, "queue", app.createUniqueWorkerName(wc, "one.example.com"))
280-
// on collision, the name is qualified with the server name
281-
require.Equal(t, "two.example.com:queue", app.createUniqueWorkerName(wc, "two.example.com"))
282-
// when the qualified name is also taken, fall back to the numeric postfix
283-
require.Equal(t, "queue_1", app.createUniqueWorkerName(wc, "two.example.com"))
284-
// workers without a server keep the numeric postfix behavior
285-
require.Equal(t, "queue_2", app.createUniqueWorkerName(wc, ""))
287+
func TestWorkerBackgroundRequiresNum(t *testing.T) {
288+
d := caddyfile.NewTestDispenser(`
289+
{
290+
php_server {
291+
worker {
292+
name jobs
293+
file ../testdata/worker-with-env.php
294+
background
295+
}
296+
}
297+
}`)
298+
module := &FrankenPHPModule{}
299+
300+
err := module.UnmarshalCaddyfile(d)
301+
require.ErrorContains(t, err, `background workers must declare "num" >= 1`)
302+
}
303+
304+
func TestWorkerBackgroundRejectsMatch(t *testing.T) {
305+
d := caddyfile.NewTestDispenser(`
306+
{
307+
php_server {
308+
worker {
309+
name jobs
310+
file ../testdata/worker-with-env.php
311+
match /jobs/*
312+
background
313+
}
314+
}
315+
}`)
316+
module := &FrankenPHPModule{}
317+
318+
err := module.UnmarshalCaddyfile(d)
319+
require.ErrorContains(t, err, `"match" is not supported for background workers`)
286320
}

caddy/module.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,10 @@ func (f *FrankenPHPModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
315315
// Check if a worker with this filename already exists in this module
316316
fileNames := make(map[string]struct{}, len(f.Workers))
317317
for _, w := range f.Workers {
318+
// background workers are keyed by name, several may share a script
319+
if w.Background {
320+
continue
321+
}
318322
if _, ok := fileNames[w.FileName]; ok {
319323
return fmt.Errorf(`workers in a single "php" or "php_server" block must not have duplicate filenames: %q`, w.FileName)
320324
}

caddy/workerconfig.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import (
2222
type workerConfig struct {
2323
mercureContext
2424

25-
// Name for the worker. Default: the absolute path of the worker file, postfixed with a number if the name is already used.
25+
// Name for the worker, unique within its php_server (or among global workers). Default: the absolute path of the worker file.
2626
Name string `json:"name,omitempty"`
2727
// FileName sets the path to the worker script.
2828
FileName string `json:"file_name,omitempty"`
@@ -38,6 +38,8 @@ type workerConfig struct {
3838
MatchPath []string `json:"match_path,omitempty"`
3939
// MaxConsecutiveFailures sets the maximum number of consecutive failures before panicking (defaults to 6, set to -1 to never panick)
4040
MaxConsecutiveFailures int `json:"max_consecutive_failures,omitempty"`
41+
// Background marks this worker as a background (non-HTTP) worker.
42+
Background bool `json:"background,omitempty"`
4143

4244
options []frankenphp.WorkerOption
4345
}
@@ -139,15 +141,29 @@ func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) {
139141
}
140142

141143
wc.MaxConsecutiveFailures = v
144+
case "background":
145+
wc.Background = true
142146
default:
143-
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads", v)
147+
return wc, wrongSubDirectiveError("worker", "name, file, num, env, watch, match, max_consecutive_failures, max_threads, background", v)
144148
}
145149
}
146150

147151
if wc.FileName == "" {
148152
return wc, d.Err(`the "file" argument must be specified`)
149153
}
150154

155+
if wc.Background {
156+
if wc.Name == "" {
157+
return wc, d.Err(`background workers must have an explicit "name"`)
158+
}
159+
if len(wc.MatchPath) != 0 {
160+
return wc, d.Err(`"match" is not supported for background workers`)
161+
}
162+
if wc.Num < 1 {
163+
return wc, d.Err(`background workers must declare "num" >= 1`)
164+
}
165+
}
166+
151167
if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(wc.FileName) {
152168
wc.FileName = filepath.Join(frankenphp.EmbeddedAppPath, wc.FileName)
153169
}
@@ -166,6 +182,10 @@ func (wc *workerConfig) toWorkerOptions() ([]frankenphp.WorkerOption, error) {
166182
// options collected while provisioning the module, e.g. the Mercure hub
167183
opts = append(opts, wc.options...)
168184

185+
if wc.Background {
186+
opts = append(opts, frankenphp.WithWorkerBackground())
187+
}
188+
169189
// copy the caddy match logic and create a unique matcher function for this worker
170190
// inject the matcher into frankenphp
171191
if len(wc.MatchPath) > 0 {

docs/config.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,9 @@ You can also explicitly configure FrankenPHP using the [global option](https://c
109109
num <num> # Sets the number of PHP threads to start, defaults to 2x the number of available CPUs.
110110
env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables.
111111
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
112-
name <name> # Sets the name of the worker, used in logs and metrics. Default: absolute path of worker file
112+
name <name> # Sets the name of the worker, used in logs and metrics and exposed as $_SERVER['FRANKENPHP_WORKER']. Must be unique among global workers. Default: absolute path of the worker file.
113113
max_consecutive_failures <num> # Sets the maximum number of consecutive failures before the worker is considered unhealthy, -1 means the worker will always restart. Default: 6.
114+
background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it waits on the stream returned by frankenphp_get_worker_handle() (stream_select() or a blocking read); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it.
114115
}
115116
}
116117
}
@@ -187,17 +188,18 @@ php_server [<matcher>] {
187188
root <directory> # Sets the root folder to the site. Default: `root` directive.
188189
split_path <delim...> # Sets the substrings for splitting the URI into two parts. The first matching substring will be used to split the "path info" from the path. The first piece is suffixed with the matching substring and will be assumed as the actual resource (CGI script) name. The second piece will be set to PATH_INFO for the script to use. Default: `.php`
189190
resolve_root_symlink false # Disables resolving the `root` directory to its actual value by evaluating a symbolic link, if one exists (enabled by default).
190-
name <name> # Sets the name for this server, used to attribute workers, metrics and logs. Default: the first host matcher of the enclosing route, or the first listener address.
191+
name <name> # Sets the name for this server, used to attribute workers, metrics and logs. Default: the first host matcher of the enclosing route, or the first listener address. Suffixed with a number if another php_server resolves to the same name.
191192
env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables.
192193
file_server off # Disables the built-in file_server directive.
193194
request_body_timeout <duration> # Sets an idle timeout on request body reads: a stalled (slow POST) client is cut off while a steady upload of any size succeeds. Default: 60s. Set to 0 to disable.
194195
worker { # Creates a worker specific to this server. Can be specified more than once for multiple workers.
195196
file <path> # Sets the path to the worker script, can be relative to the php_server root
196197
num <num> # Sets the number of PHP threads to start, defaults to 2x the number of available
197-
name <name> # Sets the name for the worker, used in logs and metrics. Default: absolute path of worker file. Postfixed with a number if name is already in use.
198+
name <name> # Sets the name for the worker, used in logs and metrics and exposed as $_SERVER['FRANKENPHP_WORKER']. Must be unique within this php_server. In logs and metrics, the worker is reported as "<server name>:<name>". Default: absolute path of the worker file.
198199
watch <path> # Sets the path to watch for file changes. Can be specified more than once for multiple paths.
199200
env <key> <value> # Sets an extra environment variable to the given value. Can be specified more than once for multiple environment variables. Environment variables for this worker are also inherited from the php_server parent, but can be overwritten here.
200201
match <path> # match the worker to a path pattern. Overrides try_files and can only be used in the php_server directive.
202+
background # EXPERIMENTAL: marks this worker as a background (non-HTTP) worker; it runs the script in a loop without serving requests, "name" is required, "match" is not allowed. The script is ready once it waits on the stream returned by frankenphp_get_worker_handle() (stream_select() or a blocking read); an exit before that counts as a failure. $_SERVER['FRANKENPHP_WORKER_BACKGROUND'] is set in it. Its threads come on top of num_threads and max_threads, and max_threads is not allowed on it.
201203
}
202204
worker <other_file> <num> # Can also use the short form like in the global frankenphp block.
203205
}

docs/library.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ err := frankenphp.Init(
6060

6161
Workers declared without a server scope are global: they match by file path on any server. Since a global worker has no set of requests to match against, combining `WithWorkerMatcher()` with a global worker is a configuration error and `Init()` rejects it.
6262

63+
Worker names are unique within their server (or among global workers), so two servers may each declare a worker named `queue`. The script sees the declared name, while metrics and logs report a server-scoped worker as `<server name>:<worker name>`; server names are made unique with a numeric suffix when needed. `WithWorkerName()` resolves a name within the request's server first, then among global workers.
64+
65+
`WithWorkerBackground()` declares a [background worker](worker.md#background-workers), which runs outside the request cycle.
66+
6367
## Per-request options
6468

6569
`Server.ServeHTTP()` accepts `RequestOption`s to override the server configuration for a single request, e.g. `WithRequestDocumentRoot()`, `WithRequestSplitPath()`, `WithRequestEnv()` or `WithRequestLogger()`.

docs/metrics.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ When [Caddy metrics](https://caddyserver.com/docs/metrics) are enabled, FrankenP
1919
- `frankenphp_busy_workers{worker="[worker_name]"}`: The number of workers currently processing a request.
2020
- `frankenphp_worker_request_time{worker="[worker_name]"}`: The time spent processing requests by all workers.
2121
- `frankenphp_worker_request_count{worker="[worker_name]"}`: The number of requests processed by all workers.
22-
- `frankenphp_ready_workers{worker="[worker_name]"}`: The number of workers that have called `frankenphp_handle_request` at least once.
22+
- `frankenphp_ready_workers{worker="[worker_name]"}`: The number of workers that have reached their ready point at least once: `frankenphp_handle_request()` for HTTP workers, `frankenphp_get_worker_handle()` for background workers.
2323
- `frankenphp_worker_crashes{worker="[worker_name]"}`: The number of times a worker has unexpectedly terminated.
2424
- `frankenphp_worker_restarts{worker="[worker_name]"}`: The number of times a worker has been deliberately restarted.
2525
- `frankenphp_worker_queue_depth{worker="[worker_name]"}`: The number of queued requests.
2626

27-
For worker metrics, the `[worker_name]` placeholder is replaced by the worker name in the Caddyfile, otherwise the absolute path of the worker file will be used.
27+
`[worker_name]` is the worker name from the Caddyfile, or the absolute path of the worker file when it has none. Workers of a `php_server` block are prefixed with the name of that block: `<server name>:<worker name>`.
2828

2929
## Threads State Endpoint
3030

0 commit comments

Comments
 (0)