Skip to content

feat: declared background workers + frankenphp_get_worker_handle() - #2617

Open
nicolas-grekas wants to merge 1 commit into
php:mainfrom
nicolas-grekas:bgworker-server
Open

feat: declared background workers + frankenphp_get_worker_handle()#2617
nicolas-grekas wants to merge 1 commit into
php:mainfrom
nicolas-grekas:bgworker-server

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

A worker marked background runs its script in a loop outside the HTTP request cycle. frankenphp_get_worker_handle() hands the script a stream that reaches EOF when FrankenPHP drains the worker, so it can park on stream_select() and exit gracefully on shutdown, reboot or restart.

PHP API

frankenphp_get_worker_handle(): resource, closed when the worker is drained (the Go side closes the write end of a per-thread stop pipe). Throws when called outside a background worker. Each call dups the read fd into a fresh stream owned by its zval, so closing one never touches the fd the C side owns.

Go API

WithWorkerBackground(). Background workers attach to a Server through the existing WithWorkerServerScope(). num >= 1 is required (no lazy-start in this build) and the name is mandatory, since it is the script's identity and is exposed as $_SERVER['FRANKENPHP_WORKER'].

Lifecycle

backgroundWorkerThread implements threadHandler and mirrors workerThread's state machine: boot, re-run on cooperative exit (status 0, backoff reset), crash-restart with quadratic backoff, hard failure on max_consecutive_failures during startup only.

drain() is wired into thread.shutdown() and rebootAllThreads(), so shutdowns and watch-triggered reboots wake parked background workers instead of waiting out the force-kill grace period.

Caddy

background flag inside worker blocks, in both php_server and global ones. name is required, match is rejected. Metrics and logs use the worker name, which server-qualified naming already keeps unique across php_server blocks, so two blocks may declare the same worker name.

Deferred

  • frankenphp_ensure_background_worker() and lazy-start machinery
  • Catch-all (empty-name) workers
  • Shared-state APIs (frankenphp_set_vars / frankenphp_get_vars)
  • An orchestrator-style frankenphp_start_background_worker() runtime API; the primitives here are compatible with it

Tests

  • TestBackgroundWorkerLifecycle: boots, touches its sentinel, parks on the stop pipe, Shutdown() returns within 10s
  • TestBackgroundWorkerCrashRestarts: exit(1) on first boot, the respawned run touches the "restarted" sentinel
  • TestBackgroundWorkerOnServer: inherits the server env, FRANKENPHP_WORKER carries the name, HTTP requests on the same server still serve
  • TestBackgroundWorkerValidation: name required, num >= 1, global name namespace, request matchers rejected
  • TestWorkerBackgroundConfig / RequiresName / RejectsMatch: Caddyfile parsing

Supersedes #2543 and #2398.

Background workers are long-lived non-HTTP PHP scripts declared via
WithWorkerBackground() or the `background` flag in Caddyfile worker
blocks. The script runs in a loop: it is re-run on cooperative exit
(status 0) and restarted with a quadratic backoff on crash, failing
hard on max_consecutive_failures during startup only.

frankenphp_get_worker_handle() returns a stream over the read end of a
per-thread stop pipe; draining the thread (shutdown, reboot, handler
transition) closes the write end, so a script parked in stream_select
wakes up and can exit gracefully.

Background workers attach to a Server through the existing
WithWorkerServerScope(); their name is mandatory (it is the script's
identity, exposed as FRANKENPHP_WORKER) and lives in the global worker
namespace, which the Caddy module already qualifies per server.
@henderkes

Copy link
Copy Markdown
Contributor

Please rewrite the PR description to not be LLM slop reasoning with itself about what it did and why. I've tried reading this three times and I just can't.

@nicolas-grekas

nicolas-grekas commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Sure, I'll let you know when I'm done, for now I just let it do the rebase 馃槄

@henderkes henderkes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens here when a global background worker and a php_server scoped background worker share the same name and are both eligible for the same source file?

Comment thread frankenphp.c
}

/* Dup so the returned stream owns its fd: closing the stream (or request
* shutdown destroying it) never touches worker_stop_fds[0], which stays

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't we leaking fd's on every restart then?

Comment thread threadbackgroundworker.go
// cooperative exit: the script is re-run with a reset backoff, unless
// the thread is being drained (beforeScriptExecution checks the state)
if exitStatus == 0 {
metrics.StopWorker(worker.name, StopReasonRestart)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without any further checks here, isn't this going to keep rapidly restarting a background script that just returns early? for worker scripts we have a guard, but I don't see one here.

Comment thread phpthread.go
return
}

close(thread.drainChan)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
thread.handler.drain()
close(thread.drainChan)

@henderkes henderkes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found another one, anyway, have you tested this on windows?

Comment thread frankenphp.c
int frankenphp_set_background_worker_and_get_stop_fd_write(void) {
is_background_worker = true;
/* Bg workers don't enforce max_execution_time; disarm any lingering timer. */
zend_unset_timeout();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have to guard these #ifdef ZEND_MAX_EXECUTION_TIMERS because of macos

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants