start.php
Rama master · réplica de lectura del repositorio original.
<?php
/**
* Phlix Media Server — Worker startup entry point.
*
* Workerman 5.x supports autostart (worker processes start via `php start.php start`
* and are supervised). The classic pattern is `start.php` + `support\App::run()` (vendor/workerman/webman-framework):
* 1. Worker global state (loop, TLS context, etc.).
* 2. Per-worker init (signals, status file, ID).
* 3. Worker instances + callbacks.
* 4. `Worker::runAll()` — fork + event loop.
*
* This file follows the same structure but uses a hand-rolled Application class
* instead of Webman's App. The HTTP worker is Workerman's stock HttpServer; all
* other functionality (hub heartbeat, relay tunnel, etc.) is injected via
* per-worker `onWorkerStart` callbacks.
*
* @package Phlix\Server
*/
declare(strict_types=1);
use Phlix\Auth\AuthManager;
use Phlix\Common\Container\ContainerFactory;
use Phlix\Common\Container\Providers\AuthServicesProvider;
use Phlix\Common\Database\ConnectionPool;
use Phlix\Common\Logger\LogChannels;
use Phlix\Common\Logger\LoggerFactory;
use Phlix\Common\Logger\StructuredLogger;
use Phlix\Common\RateLimit\RateLimiterInterface;
use Phlix\Common\RateLimit\RateLimitProfiles;
use Phlix\Config\EffectiveConfig;
use Phlix\Media\Transcoding\SegmentCacheDirectory;
use Phlix\Server\Core\Application;
use Phlix\Server\Http\RequestAuthenticator;
use Phlix\Server\Runtime\BootIdentity;
use Phlix\Server\Runtime\ListenEndpoints;
use Phlix\Server\Runtime\PidFile;
use Phlix\Server\Workerman\HttpHandler;
use Phlix\Stats\Metrics\MetricsCollector;
use Phlix\Stats\Metrics\MetricsFlushService;
use Workerman\Events\Select;
use Workerman\Worker;
require __DIR__ . '/vendor/autoload.php';
// -----------------------------------------------------------------------------
// 0. Pre-flight checks
// -----------------------------------------------------------------------------
if (!function_exists('pcntl_fork')) {
echo "ERROR: pcntl extension is required for Workerman.\n";
exit(1);
}
// Refuse to boot with a missing or default JWT signing key (S5). The same secret
// also derives the media signed-URL key (see Phlix\Auth\SignedUrl::fromEnv), so a
// default/empty value would make both JWTs and stream URLs forgeable. Skipped in
// the test environment by AuthServicesProvider::assertSecretConfigured(). This runs
// before any Worker is created or Worker::runAll() forks, so a misconfigured server
// fails fast with a clear CRITICAL message instead of serving with a guessable key.
try {
AuthServicesProvider::assertSecretConfigured();
} catch (\RuntimeException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
exit(1);
}
// -----------------------------------------------------------------------------
// 1. Configuration (built first so the coroutine-runtime setup below can read
// the `coroutine` settings).
// -----------------------------------------------------------------------------
/** @var array<string, mixed> $config */
$config = include __DIR__ . '/config/server.php';
$config['db_config_path'] = __DIR__ . '/config/database.php';
$config['logger_config_path'] = __DIR__ . '/config/logger.php';
$config['web_portal'] = array_merge(
is_array($config['web_portal'] ?? null) ? $config['web_portal'] : [],
['template_dir' => __DIR__ . '/public/templates']
);
LoggerFactory::init($config['logger_config_path']);
// C4 (INV-A P2): one-shot boot disclosure when PHLIX_LOG_LEVEL forces the
// global floor below info — at debug every served request pays ~10 sync
// Monolog writes (stdout mode can stall the event loop on log backpressure).
// Disclosure only, never an override; runs in the MASTER pre-fork, so the
// line appears exactly once per boot. StructuredLogger::verboseFloorDisclosure
// stays null for unset/empty/unknown values (an inert knob has nothing to
// confess) and at info-and-above.
$verboseFloorDisclosure = StructuredLogger::verboseFloorDisclosure();
if ($verboseFloorDisclosure !== null) {
LoggerFactory::get(LogChannels::APPLICATION)->warning($verboseFloorDisclosure);
}
// C5 (FIELD-hub-phlix): the HLS segment cache must be WRITABLE at boot.
// `hls.segment_dir` honors PHLIX_SEGMENT_DIR so the cache can live OUTSIDE
// the unit's PrivateTmp namespace (install.sh provisions /var/phlix/hls) and
// survive restarts. A misprovisioned override would otherwise boot "fine"
// and silently re-pay the cold-cache tax — 3–6.6 s of inline transcode per
// first-played segment — forever; that is a misconfiguration, so refuse to
// start with a clear, actionable message, pre-fork, exactly like the
// JWT-secret gate in section 0. Unset env keeps the historical temp default,
// which still runs the same check (a machine with an unwritable /tmp now
// learns it at boot instead of mid-playback). A non-string value is left to
// the provider's documented runtime fallbacks — the guard covers the shape
// config/server.php actually ships.
$segmentDir = is_array($config['hls'] ?? null) ? ($config['hls']['segment_dir'] ?? null) : null;
if (is_string($segmentDir)) {
try {
SegmentCacheDirectory::assertUsable($segmentDir);
} catch (\RuntimeException $e) {
fwrite(STDERR, $e->getMessage() . "\n");
error_log($e->getMessage());
exit(1);
}
}
// S445/MED-1(1): pin the SyncPlay write-through bridge's shared secret HERE,
// pre-fork, so every forked worker (14 HTTP publishers + the WS listener + the
// timer workers) inherits ONE value through memory — no on-disk secret needed
// in the served topology. Resolution: syncplay_bridge.token (config) →
// PHLIX_SYNCPLAY_BRIDGE_TOKEN (env) → fresh random bytes; a process that
// reaches the bridge without a boot pin self-boots a process-local secret and
// warns once (cross-process frames then fail CLOSED). Split topologies must
// set the config/env token. See docs/dev/SYNCPLAY_WRITE_THROUGH_BRIDGE.md.
$syncPlayBridgeConfig = is_array($config['syncplay_bridge'] ?? null) ? $config['syncplay_bridge'] : [];
$syncPlayBridgeToken = $syncPlayBridgeConfig['token'] ?? null;
\Phlix\Session\SyncPlay\SyncPlayBridge::initToken(
is_string($syncPlayBridgeToken) && $syncPlayBridgeToken !== '' ? $syncPlayBridgeToken : null
);
// Point Workerman's master PID file at config/server.php's `worker.pid_file`.
// MUST run in the master, before Worker::runAll(). Without this, Workerman uses
// its own default (dirname(start.php)/workerman.start.php.pid) while
// AdminRestartController reads the configured path — so the admin restart
// endpoint 500s with "PID file not found" on every real box. Non-fatal: an
// unusable directory leaves Workerman's default in place rather than aborting
// boot. {@see \Phlix\Server\Runtime\PidFile}
//
// Deferred-item #15 (devops2 minor 2): stamp the boot identity FIRST and make
// the degraded-restart-rail branch a LOG-CHANNEL RECORD, not just a bare
// STDERR line — one error_log() line carrying instance token, the configured
// path and the consequence, so a journal search for the instance finds the
// degradation beside every other line this boot emits. The fail-loud option
// was weighed and rejected: the class's own ratified posture (serving media
// outranks the restart button, M8 boot-abort family) stands; what was missing
// was correlation, not a second abort path. The /health-key half of the
// review's "health signal" ask is a new payload surface and stays gated with
// the M18 readiness-split family (contracts wave).
BootIdentity::stamp();
$pidFilePath = PidFile::apply($config);
if ($pidFilePath === null) {
$configuredPidFile = PidFile::configuredPath($config) ?? '(unset)';
$pidWarning = '[phlix] boot rail DEGRADED: worker.pid_file is unusable ('
. $configuredPidFile . '); falling back to Workerman\'s default PID path. '
. 'POST /api/v1/admin/restart will not work until that directory is writable. '
. BootIdentity::describe();
fwrite(STDERR, "[phlix] WARNING: " . $pidWarning . "\n");
error_log($pidWarning);
}
// -----------------------------------------------------------------------------
// 0. Coroutine runtime — make Swoole the eventLoop driver and enable a CURATED
// set of coroutine hooks in the master process before any Worker is created.
//
// Hooking *every* native call (SWOOLE_HOOK_ALL) crashed the HTTP worker with
// recurring general-protection faults inside swoole.so (`exit with status
// 139` = SIGSEGV) on the PHP 8.5 / Swoole 6.2.1 / kernel-7 (io_uring) stack —
// the faults correlate with hooked file IO (io_uring), process spawns (the
// on-demand ffmpeg transcode shells out) and native curl. {@see SwooleRuntime}
// keeps the socket/sleep/stream hooks (so the coroutine MySQL pool + network
// IO still yield) and drops FILE/PROC/CURL/STDIO, which run as ordinary
// blocking calls. This is the minimum surface area needed for the media
// server's async workloads.
// -----------------------------------------------------------------------------
// Swoole must be Workerman's event loop driver, and Worker::$eventLoopClass MUST
// be assigned here in the MASTER process — before any Worker exists and before
// Worker::runAll() — NEVER inside onWorkerStart.
//
// Worker::run() dispatches the per-worker callback with
// match (Worker::$eventLoopClass) { Swoole::class => Coroutine::create($cb),
// default => (new \Fiber($cb))->start() }
// and Workerman\Coroutine\Context::initDriver() picks its context backend
// (Swoole vs Fiber) from that SAME static. If eventLoopClass is only set later
// (inside onWorkerStart), run() has already taken the plain-Fiber branch while
// the context driver resolves to Swoole — so the finally{} Context::destroy()
// calls Swoole\Coroutine::getContext() OUTSIDE any coroutine, gets null, and
// fatals: "Call to a member function exchangeArray() on null". Every worker then
// dies on startup and Workerman re-forks in a tight loop (100% CPU, no service).
// Setting it in the master keeps dispatch and the context driver consistent: the
// worker callback runs inside a real Swoole coroutine where getContext() is valid.
if (extension_loaded('swoole')) {
Worker::$eventLoopClass = \Workerman\Events\Swoole::class;
// F-1c: the master deliberately does NOT enable coroutine hooks pre-fork.
// A pre-fork enable made the openssl function swap INHERITABLE, and the
// swap is sticky: no per-bit exclusion afterwards can un-hook a child
// (production-host mask matrix: narrowed mask, never-TLS-enabled master
// mask, and a verified setHookFlags replacement ALL still stranded the
// async TLS handshake at bytes_sent:1533; a Select child of a hooked
// master crash-looped 'API must be called in the coroutine'). The one
// regime that completes the relay TLS handshake is a child that was
// NEVER hooked on the Select loop — which requires an unhooked master.
// Non-relay children install hooks physically themselves: the Swoole
// event-adapter constructor re-asserts, and every hooked worker's
// onWorkerStart delivers+proves the curated mask via HookDelivery
// (S433 — the only physically-effective API path). The static assignment
// above stays pre-fork: it keeps run()'s coroutine-dispatch and
// Coroutine\Context's driver resolution consistent per child (the
// exchangeArray-on-null crash loop the original comment documented);
// the relay child's $worker->eventLoop override flips that static inside
// its own process before run() dispatches, selecting the Fiber branch.
} else {
trigger_error(
'Swoole extension not detected — coroutine runtime will not be active. Install ext-swoole to enable.',
E_USER_WARNING
);
}
// Enforce the CURATED coroutine hook allowlist inside every worker — and PROVE
// it landed. Workerman's Swoole event adapter constructor re-installs
// SWOOLE_HOOK_ALL once per worker (right before onWorkerStart), silently
// re-enabling the FILE(io_uring)/PROC/CURL/blocking-function hooks the allowlist
// exists to keep off the PHP 8.5 / Swoole 6.2.1 / kernel-7 io_uring stack (they
// produced 200+ worker SIGSEGVs a day; exit status 139).
//
// The pre-S433 remedy here called Coroutine::set(['hook_flags' => …]) from
// INSIDE the worker coroutine — measured on this estate's stack, that API
// updates only the REPORTED mask: getOptions() showed the curated 0x42fe while
// every handler stayed physically installed, so the mitigation could reach no
// worker and the standard check reported SUCCESS either way. The S433 remedy is
// behavioural: HookDelivery re-installs via Runtime::enableCoroutine() (the
// full-mask replacement API that physically un-swaps handlers) and proves
// delivery with a sibling-tick probe around a blocking cURL — never a
// getOptions() read. A worker whose configured mask did not land throws at
// startup: the loud crash-loop IS the feature — running without the allowlist
// is the site-down state this whole mitigation exists to prevent.
// F-1c: the body's first act is the role gate — Select-loop children (the
// relay-tunnel worker) are NEVER hooked; delivery is gated by role, not by
// deleting any call site (the seven-site census in the guard stands).
// {@see \Phlix\Server\Runtime\HookDelivery} {@see SwooleRuntime}
$applyCuratedCoroutineHooks = static function () use ($config): void {
if (Worker::$eventLoopClass === Select::class) {
return;
}
if (!extension_loaded('swoole')) {
return;
}
$mask = \Phlix\Server\Runtime\SwooleRuntime::runtimeHookMask($config);
$sample = \Phlix\Server\Runtime\HookDelivery::enforceAndVerify($mask);
Worker::log(sprintf(
'[phlix] worker #%d coroutine hook allowlist DELIVERED: mask=0x%04x, probe %d ticks / %.0f ms',
(int) (getmypid() ?: -1),
$mask,
$sample['ticks'],
$sample['blocked_ms']
));
};
// -----------------------------------------------------------------------------
// 2. Public root (used by HttpHandler for SSR asset lookups).
// -----------------------------------------------------------------------------
$publicRoot = realpath(__DIR__ . '/public') ?: __DIR__ . '/public';
// -----------------------------------------------------------------------------
// 3. HTTP worker.
//
// The container can't be built before fork (it caches workerman/mysql
// PDO sockets and the like). Build it inside onWorkerStart so each
// child has its own copy of long-lived state.
// -----------------------------------------------------------------------------
// R14-06: the bind used to be a hardcoded `http://0.0.0.0:8096` with
// count 14 while config/server.php shipped server.host/port, worker.count
// and process.* that nothing binding a socket read. Resolution is now
// env > config > shipped default, byte-identical when neither is set, and
// the resolved host/port are written BACK into $config before the fork so
// every later reader (the DLNA advertised port at §dlna, the relay's
// local-address derivation, container healthchecks' expectations) sees the
// socket that actually accepts. {@see \Phlix\Server\Runtime\ListenEndpoints}
// SH-025: the auto arm additionally consults the cgroup CPU quota, because
// a k8s `cpu` limit (CFS quota) is invisible to nproc — 14 workers inside a
// 2-core pod oversubscribed the limit and put the whole pod into an OOMKill
// lottery. null (no visible limit) keeps the shipped 14 exactly as before.
$httpListen = ListenEndpoints::http(
$config,
ListenEndpoints::envValue(ListenEndpoints::ENV_HTTP_HOST),
ListenEndpoints::envValue(ListenEndpoints::ENV_HTTP_PORT),
ListenEndpoints::envValue(ListenEndpoints::ENV_HTTP_WORKER_COUNT),
ListenEndpoints::detectCpuQuotaCores()
);
$config['server'] = is_array($config['server'] ?? null) ? $config['server'] : [];
$config['server']['host'] = $httpListen['host'];
$config['server']['port'] = $httpListen['port'];
$httpWorker = new Worker($httpListen['socketName']);
$httpWorker->count = $httpListen['workerCount'];
$httpWorker->reloadable = ListenEndpoints::reloadable($config);
$httpWorker->reusePort = ListenEndpoints::reusePort($config);
$httpWorker->name = 'phlix-server-http';
$httpWorker->onWorkerStart = static function (Worker $w) use ($config, $publicRoot, $applyCuratedCoroutineHooks): void {
$applyCuratedCoroutineHooks();
// Overlay the persisted `server_settings` overrides onto the boot config
// BEFORE the container is built, so every DI provider that reads boot
// config observes the EFFECTIVE value. This is what makes the schema's
// `"restart": true` keys actually apply on a restart/graceful reload —
// the master's $config is frozen at fork, but this runs per worker start.
// Never throws: an unreachable settings store leaves $config untouched.
// {@see \Phlix\Config\EffectiveConfig}
$config = EffectiveConfig::bootstrapAndOverlay($config);
$container = ContainerFactory::create($config);
/** @var AuthManager $authManager */
$authManager = $container->get(AuthManager::class);
// HttpHandler arg #2 is a RequestAuthenticator (the shared auth
// collaborator), NOT the raw AuthManager. Wrap the AuthManager in one —
// the same shape the pre-S171 CGI entry point used (public/index.php is
// deleted; the constructor contract is pinned by HttpHandlerWiringTest).
$authenticator = new RequestAuthenticator($authManager);
// Build the full route table + middleware chain once per worker.
// {@see Application::__construct()} only registers routes/middleware
// — it does NOT call boot() or run() and therefore does not start
// hub/relay/discovery/newsletter/backup timers. The hub heartbeat
// and relay tunnels still need their own one-shot startup; that's
// wired below outside this closure so it runs once per worker too.
/** @var ConnectionPool $connectionPool */
$connectionPool = $container->get(ConnectionPool::class);
$application = new Application($container, $config, $connectionPool);
// Warm WebPortalRouter once per worker so its heavy, I/O-yielding factory
// (it resolves ~20 services and performs the first pooled DB handshake +
// settings reads) is cached in php-di's resolvedEntries BEFORE this worker
// accepts concurrent /api/ requests. WebPortalRouter is otherwise resolved
// LAZILY per request (see HttpHandler step 1b). php-di's cycle guard
// `entriesBeingResolved` is INSTANCE state on the single per-worker
// container and is NOT coroutine-safe: if request-coroutine A is mid-resolve
// when its factory yields on DB I/O, a second concurrent request-coroutine B
// sees the leaked guard flag and throws a spurious "Circular dependency
// detected ... WebPortalRouter -> WebPortalRouter" 500. This is a transient
// cold-start race after every worker restart. Resolving it here — once, in
// this single onWorkerStart coroutine (Worker::run() dispatches the callback
// via Coroutine::create), after the pool is ready and BEFORE $w->onMessage
// is assigned below (so no request can race it) — caches the instance so
// every later get() short-circuits on resolvedEntries before the guard runs.
// Non-fatal: the lazy per-request path still applies if this throws.
if ($container->has(\Phlix\Server\WebPortal\WebPortalRouter::class)) {
try {
$container->get(\Phlix\Server\WebPortal\WebPortalRouter::class);
} catch (\Throwable $e) {
LoggerFactory::get(LogChannels::HTTP)->debug(
'WebPortalRouter warm-up skipped (will resolve lazily per request)',
['error' => $e->getMessage()],
);
}
}
// F1 — plugin boot activation. Re-attach every persisted-enabled plugin into
// THIS HTTP worker's ListenerRegistry + SourceRegistry, so playback events
// reach scrobbler plugins (trakt/lastfm) and metadata-source plugins are
// registered immediately after a restart — not only in whichever single
// worker happened to serve an admin "enable" request. WIRE-ONLY (no
// persist/audit — the row is already enabled): {@see PluginLoader::bootstrapEnabled()}.
// Every plugin's onEnable() is de-blocked/non-blocking, so this cannot hang
// the worker at boot (the item-5c3 landmine that reverted the naive version).
// Non-fatal: a bootstrap failure must never stop the worker from serving.
try {
$container->get(\Phlix\Plugins\PluginLoader::class)->bootstrapEnabled();
} catch (\Throwable $e) {
LoggerFactory::get(LogChannels::PLUGINS)->error(
'plugin boot activation failed (HTTP worker)',
['worker_id' => $w->id ?? '?', 'error' => $e->getMessage()],
);
}
// S44 — register enabled external auth providers (OIDC/LDAP) into THIS HTTP
// worker's in-memory AuthProviderRegistry. The registry is per-worker state,
// so a provider enabled via the admin API in one worker is otherwise absent
// from the others until they restart; this boot pass re-attaches every
// provider whose `auth.<name>.enabled` server-setting is on AND whose plugin
// settings are complete. It reads settings + instantiates the provider only
// (OIDC discovery/JWKS are fetched lazily on first authenticate), so it does
// NO blocking I/O at boot. Scoped to the HTTP worker on purpose: provider
// login (AuthController / OidcCallbackController) is only reachable on the
// HTTP dispatch path — the WS/heartbeat/relay/managed-scan workers never run
// it, so registering there would only hold dead instances (the same
// rationale the plugin-bootstrap block uses to gate itself to library-scan).
// Non-fatal: a failure must never stop the worker from serving.
try {
$container->get(\Phlix\Auth\AuthProviderBootstrapper::class)->registerEnabledProviders();
} catch (\Throwable $e) {
LoggerFactory::get(LogChannels::AUTH)->error(
'auth provider boot registration failed (HTTP worker)',
['worker_id' => $w->id ?? '?', 'error' => $e->getMessage()],
);
}
// SV-0.1: probe hardware acceleration exactly once per worker at start (not
// per request) and log the chosen accelerator a single time. Resolving the
// FfmpegRunner runs the DI factory which calls setConfig() + the probe; the
// probe is idempotent (guarded by $hwaccelProbed) so this explicit call is
// safe and merely guarantees it happened at boot. It runs OUTSIDE any
// coroutine here, so the probe's blocking exec cannot stall the event loop.
// Trace context for the single structured worker-start log emitted below.
$workerTraceId = substr(md5((string)mt_rand() . microtime(true)), 0, 12);
$workerPid = getmypid();
/** @var \Phlix\Media\Transcoding\FfmpegRunner $ffmpegRunner */
$ffmpegRunner = $container->get(\Phlix\Media\Transcoding\FfmpegRunner::class);
// SV-0.1: The container's FfmpegRunner factory already calls probeHardwareAcceleration()
// (guarded by $hwaccelProbed to ensure idempotence), so the explicit probe call is
// redundant. The runner is ready to use; log the summary for visibility.
$hwSummary = $ffmpegRunner->getHardwareAccelerationSummary();
LoggerFactory::get(LogChannels::STREAMING)->info(
'Hardware acceleration probed at worker start',
array_merge($hwSummary, [
'trace_id' => $workerTraceId,
'worker_id' => $w->id ?? '?',
'pid' => $workerPid,
]),
);
// SV-3.1e: DVR boot recovery. resumeActiveRecordings() reconciles
// `livetv_recordings` after a restart — re-attaches live ffmpeg children,
// marks orphaned rows failed, and re-arms overdue schedules. It must run
// exactly ONCE per boot, not once per HTTP worker, so it is gated on the
// first worker in the group ($w->id === 0). Resolving LiveTvManager links
// the shared Recorder singleton (so tuner resolution works) and the call
// runs OUTSIDE any coroutine here — resumeActiveRecordings()'s process
// checks + detached spawns are ordinary blocking calls, valid at boot just
// like the hwaccel probe above (no coroutine-only work to guard). It is
// recovery/timers belong only to the long-running Workerman master — the
// deleted single-shot CGI path (public/index.php, S171) never had them.
// (Scheduler Timers are SV-3.1c; this is only the recovery bootstrap.)
if ((int) $w->id === 0) {
try {
/** @var \Phlix\LiveTv\LiveTvManager $liveTvManager */
$liveTvManager = $container->get(\Phlix\LiveTv\LiveTvManager::class);
$recoveryStats = $liveTvManager->bootstrap();
LoggerFactory::get(LogChannels::LIVETV)->info(
'DVR boot recovery complete',
$recoveryStats,
);
} catch (\Throwable $e) {
// A DVR recovery failure must never stop the HTTP worker from
// serving. Log and continue.
LoggerFactory::get(LogChannels::LIVETV)->error(
'DVR boot recovery failed',
['error' => $e->getMessage()],
);
}
}
// SV-3.1c: DVR scheduler + timed-stop. Arm a single periodic Timer (worker 0
// only, mirroring the boot-recovery gate above so 14 HTTP workers don't each
// run the scan) that on every tick (a) starts recordings whose start_time
// (minus pre-padding) has arrived — arming a per-recording one-shot stop
// timer at end_time + post_padding — and (b) stops any in-progress recording
// whose effective end (end_time + post_padding) has already passed but whose
// one-shot timer was lost (the boot-recovery / missed-timer safety net). The
// Workerman Swoole event adapter wraps every timer callback in
// Coroutine::create() (safeCall), so the DB work + process kills inside the
// tick run in a valid coroutine context (hooked PDO can yield); the body is
// additionally wrapped in try/catch so a scan error can never bubble out and
// kill the worker. Timers belong only to the resident daemon — the
// single-shot CGI front controller (public/index.php, deleted by S171) ran none.
if ((int) $w->id === 0) {
try {
/** @var \Phlix\LiveTv\Recording\RecordingScheduler $recordingScheduler */
$recordingScheduler = $container->get(\Phlix\LiveTv\Recording\RecordingScheduler::class);
/** @var array<string, mixed> $livetvCfg */
$livetvCfg = is_array($config['livetv'] ?? null) ? $config['livetv'] : [];
/** @var array<string, mixed> $dvrCfg */
$dvrCfg = is_array($livetvCfg['dvr'] ?? null) ? $livetvCfg['dvr'] : [];
$intervalRaw = $dvrCfg['scheduler_interval_seconds'] ?? 30;
$schedulerInterval = is_int($intervalRaw) && $intervalRaw > 0
? $intervalRaw
: ((is_numeric($intervalRaw) && (int) $intervalRaw > 0) ? (int) $intervalRaw : 30);
\Workerman\Timer::add(
$schedulerInterval,
static function () use ($recordingScheduler): void {
try {
$recordingScheduler->tick();
} catch (\Throwable $e) {
LoggerFactory::get(LogChannels::LIVETV)->error(
'DVR scheduler tick failed',
['error' => $e->getMessage()],
);
}
},
);
LoggerFactory::get(LogChannels::LIVETV)->info(
'DVR scheduler timer armed',
['interval_seconds' => $schedulerInterval],
);
} catch (\Throwable $e) {
// A scheduler-wiring failure must never stop the HTTP worker from
// serving. Log and continue.
LoggerFactory::get(LogChannels::LIVETV)->error(
'DVR scheduler timer wiring failed',
['error' => $e->getMessage()],
);
}
}
// SV-3.6a: Trakt → Phlix watched-history pull-sync. The Trakt scrobbler
// plugin wires only the PUSH direction inline (PlaybackStopped → Trakt); the
// PULL direction (TraktHistorySync::syncTraktToPhlix) was fully built but had
// no scheduler, so a connected Trakt account's watched history never flowed
// back into local WatchHistory. Plugins expose NO scheduling hook
// (LifecycleInterface has none), so the periodic pull Timer must live here in
// the resident HTTP worker — worker-0-gated (mirroring the DVR boot-recovery /
// scheduler gates above) so 14 HTTP workers don't each run the sync; the
// single-shot CGI path (public/index.php, deleted by S171) ran no timers.
//
// Like the DVR scheduler above, the Workerman Swoole event adapter wraps every
// Timer callback in Coroutine::create() (safeCall), so the pull's de-blocked
// HTTP client (SV-3.6b) + its \Co\sleep 429-backoff yield to the event loop
// inside the tick; the body is additionally wrapped in try/catch so a sync
// failure can never bubble out and kill the worker or cancel the timer. The
// https transport still takes the accepted blocking-curl branch (EventLoopTls),
// so the work is deliberately kept on worker 0 only (the gate) with no extra
// concurrency.
//
// The tick resolves a FRESH entry instance each fire via
// PluginLoader::getEntryInstance() (which applies the DB-persisted settings so
// runtime enable/token changes are picked up) — the resident server does not
// bootstrapEnabled() plugins at boot, so we cannot rely on a live enabled
// instance existing in this worker process.
if ((int) $w->id === 0) {
try {
/** @var \Phlix\Plugins\PluginLoader $pluginLoader */
$pluginLoader = $container->get(\Phlix\Plugins\PluginLoader::class);
// Manifest name of the Trakt scrobbler (phlix-plugin-trakt/plugin.json).
$traktPluginName = 'phlix-plugin-trakt';
$installedTrakt = $pluginLoader->getInstalled($traktPluginName);
$traktSettings = \Phlix\Server\Integrations\Trakt\TraktSettings::fromArray(
$installedTrakt->settings,
);
$traktIntervalMinutes = $traktSettings->syncIntervalMinutes;
// Manifest "master" on/off switch, persisted in settings_json (default
// false in plugin.json). It is SEPARATE from the catalog-level
// plugins.enabled column ($installedTrakt->enabled): each tick's
// TraktPlugin::isConfigured() also requires this master flag (read from
// $settings['enabled'] in TraktPlugin::configure()). We mirror that same
// check here so we only ARM when a tick would actually do work — a
// catalog-enabled + connected account with the master switch OFF no
// longer arms a perpetually-no-op timer.
$traktMasterEnabled = ($installedTrakt->settings['enabled'] ?? false) === true;
// Respect a disabled config: only arm when the plugin is catalog-enabled,
// the master switch is on, two-way sync is on, and the interval is a
// positive number of minutes — the same effective condition each tick
// requires (per-tick token/username gating is still enforced inside
// syncHistoryFromTrakt(), so a stale-but-armed timer safely no-ops).
// Limitation (pre-existing, same as the rest of the plugin system):
// arming runs once at onWorkerStart, so toggling the master switch ON
// after boot won't arm until the next server restart.
if (
$installedTrakt->enabled
&& $traktMasterEnabled
&& $traktSettings->syncEnabled
&& $traktIntervalMinutes > 0
) {
// S340 — boot catch-up. A bare `Timer::add($intervalMinutes*60, …)`
// fires its FIRST tick a full interval after the process starts, so
// on a box restarted (deploy, reboot, admin Restart) more often than
// the interval it fires NEVER. Following the S308 shape, the timer
// is a SHORT SWEEP and the due-decision lives in TraktSyncBoot:
// each sweep reads the persisted last-run (server_settings
// `trakt.sync_last_run_at`), runs the pull only when the configured
// interval has genuinely elapsed, and persists the new last-run. The
// first sweep a minute after every boot therefore catches up exactly
// when a poll was missed; no I/O is added to the boot path, and the
// plugin (an external, non-vendored package) is injected as the
// $sync callback so the testable surface is the due-decision itself.
\Workerman\Timer::add(
\Phlix\Server\Integrations\Trakt\TraktSyncBoot::DEFAULT_SWEEP_SECONDS,
static function () use ($container, $pluginLoader, $traktPluginName, $traktIntervalMinutes): void {
try {
\Phlix\Server\Integrations\Trakt\TraktSyncBoot::runIfDue(
$container->get(\Phlix\Admin\SettingsRepository::class),
$traktIntervalMinutes * 60,
static function () use ($pluginLoader, $container, $traktPluginName): void {
try {
$plugin = $pluginLoader->getEntryInstance($traktPluginName);
if ($plugin instanceof \Phlix\Plugins\Scrobbler\Trakt\TraktPlugin) {
$written = $plugin->syncHistoryFromTrakt($container);
LoggerFactory::get(LogChannels::PLUGINS)->info(
'Trakt pull-sync tick complete',
['items_written' => $written],
);
}
} catch (\Throwable $e) {
LoggerFactory::get(LogChannels::PLUGINS)->error(
'Trakt pull-sync tick failed',
['error' => $e->getMessage()],
);
}
},
);
} catch (\Throwable $e) {
// A sweep-wiring failure (e.g. the settings store is
// unreachable) must never take the worker's tick.
LoggerFactory::get(LogChannels::PLUGINS)->error(
'Trakt pull-sync sweep failed',
['error' => $e->getMessage()],
);
}
},
);
LoggerFactory::get(LogChannels::PLUGINS)->info(
'Trakt pull-sync timer armed',
[
'sweep_seconds' => \Phlix\Server\Integrations\Trakt\TraktSyncBoot::DEFAULT_SWEEP_SECONDS,
'interval_minutes' => $traktIntervalMinutes,
],
);
} else {
LoggerFactory::get(LogChannels::PLUGINS)->debug(
'Trakt pull-sync timer not armed (plugin disabled, master off, sync off, or interval <= 0)',
[
'enabled' => $installedTrakt->enabled,
'master_enabled' => $traktMasterEnabled,
'sync_enabled' => $traktSettings->syncEnabled,
'interval_minutes' => $traktIntervalMinutes,
],
);
}
} catch (\Phlix\Plugins\Exception\PluginNotFoundException) {
// Trakt plugin not installed — nothing to sync. Not an error.
LoggerFactory::get(LogChannels::PLUGINS)->debug(
'Trakt plugin not installed; pull-sync timer not armed',
);
} catch (\Throwable $e) {
// A pull-sync wiring failure must never stop the HTTP worker from
// serving. Log and continue.
LoggerFactory::get(LogChannels::PLUGINS)->error(
'Trakt pull-sync timer wiring failed',
['error' => $e->getMessage()],
);
}
}
/** @var MetricsCollector $metricsCollector */
$metricsCollector = $container->get(MetricsCollector::class);
$w->onMessage = new HttpHandler(
$container,
$authenticator,
$publicRoot,
$application,
$metricsCollector,
);
// S2 metrics: arm the flush timer in this HTTP worker.
if ($metricsCollector->isEnabled()) {
/** @var array{flush_interval_seconds?: int} $metricsConfig */
$metricsConfig = $config['metrics'] ?? [];
/** @var MetricsFlushService $flushService */
$flushService = $container->get(MetricsFlushService::class);
$flushInterval = (int) ($metricsConfig['flush_interval_seconds'] ?? 5);
\Workerman\Timer::add(
$flushInterval,
static function () use ($flushService, $w): void {
$flushService->flush((int) $w->id, (int) time());
},
);
}
};
// -----------------------------------------------------------------------------
// 4. (Future) WebSocket worker on port 8097 for sync-play, etc.
// Wire src/Server/WebSocket/WebSocketServer.php here once it's
// needed at boot time. For now the HTTP worker alone covers the
// REST + SSR surface.
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// 4a. WebSocket worker for SyncPlay realtime communication (SP1).
//
// SyncPlay requires exactly ONE authoritative SyncPlayManager shared across all
// WS connections, so this worker runs as count=1 on port 8097 (separate from the
// HTTP workers on 8096). The manager is constructed once in onWorkerStart and
// its state persists for the lifetime of this worker process.
//
// Architecture:
// - WebSocketServer accepts an injected MessageHandler so the same handler
// instance is used for both SyncPlay message routing and general WS events.
// - SyncPlayManager::initialize() registers the per-type callbacks that route
// incoming SyncPlay messages to the appropriate handler methods.
// - ConnectionPool and MessageHandler are singletons shared within this worker.
// -----------------------------------------------------------------------------
try {
// R14-06: was hardcoded `websocket://0.0.0.0:8097`; now env > config >
// default with the resolved pair written back into $config['websocket']
// BEFORE the closure below captures $config — so the WebSocketServer
// metadata it builds in the child (status host/port) always describes
// the socket that actually listens. The count stays 1 by law: exactly
// one authoritative SyncPlayManager may own room state (§4a).
$wsListen = ListenEndpoints::webSocket(
$config,
ListenEndpoints::envValue(ListenEndpoints::ENV_WS_HOST),
ListenEndpoints::envValue(ListenEndpoints::ENV_WS_PORT)
);
$config['websocket'] = is_array($config['websocket'] ?? null) ? $config['websocket'] : [];
$config['websocket']['host'] = $wsListen['host'];
$config['websocket']['port'] = $wsListen['port'];
$wsWorker = new Worker($wsListen['socketName']);
$wsWorker->count = 1;
$wsWorker->reloadable = ListenEndpoints::reloadable($config);
$wsWorker->reusePort = ListenEndpoints::reusePort($config);
$wsWorker->name = 'phlix-server-ws';
$wsWorker->onWorkerStart = static function (Worker $w) use ($config, $applyCuratedCoroutineHooks): void {
$applyCuratedCoroutineHooks();
// Effective-config overlay, mirroring the HTTP worker (§7 dual entry
// points / every fork). {@see \Phlix\Config\EffectiveConfig}
$config = EffectiveConfig::bootstrapAndOverlay($config);
// Build the container inside the fork so each worker owns its own state.
$container = ContainerFactory::create($config);
// Create the shared MessageHandler and ConnectionPool singletons.
$connections = \Phlix\Server\WebSocket\ConnectionPool::getInstance();
$messageHandler = new \Phlix\Server\WebSocket\MessageHandler($connections);
// Construct ONE authoritative SyncPlayManager and initialize it with the
// message handler so SyncPlay message types are routed to their handlers.
/** @var \Phlix\Common\Logger\StructuredLogger $logger */
$logger = $container->get('logger.websocket');
// MED-2 (SyncPlay audit): group_list visibility is members-or-admin —
// wire the same active-only admin predicate (findAdminById) the REST
// admin routes enforce so admins still see every room from the WS rail.
$syncPlayManager = new \Phlix\Session\SyncPlay\SyncPlayManager(
$logger,
adminUsers: $container->get(\Phlix\Auth\UserRepository::class),
);
$syncPlayManager->initialize($messageHandler);
// SP5: Set the snapshot service so mutations are published to the DB
// snapshot table, allowing HTTP workers to read the authoritative state.
/** @var \Phlix\Session\SyncPlay\SyncPlaySnapshotService $snapshotService */
$snapshotService = $container->get(\Phlix\Session\SyncPlay\SyncPlaySnapshotService::class);
$syncPlayManager->setSnapshotService($snapshotService);
// S445 write-through bridge (WS side): ingest REST-worker mutations as
// frames on the private unix socket so rooms created or mutated
// through the HTTP rails are reflected in THIS worker's live tables
// without a restart. The WS worker stays the single authority on live
// in-memory state and never becomes a DB reader — it is fed by the
// published deltas (persist-then-publish ordering on the REST side).
// A listener failure must NEVER stop :8097 from serving; a bind error
// or a missing event loop downgrades to a logged warning (model +
// loss posture: docs/dev/SYNCPLAY_WRITE_THROUGH_BRIDGE.md).
$bridgeConfigRaw = $config['syncplay_bridge'] ?? [];
$bridgeConfig = is_array($bridgeConfigRaw) ? $bridgeConfigRaw : [];
if (\Phlix\Session\SyncPlay\SyncPlayBridge::isEnabled($bridgeConfig)) {
try {
$bridgeListener = new \Phlix\Session\SyncPlay\SyncPlayBridgeListener(
\Phlix\Session\SyncPlay\SyncPlayBridge::socketPathFromConfig($bridgeConfig),
$logger
);
$bridgeListener->setApplier(static function (array $frame) use ($syncPlayManager): void {
$syncPlayManager->applyBridgeFrame($frame);
});
$bridgeListener->listen();
if (!$bridgeListener->attachToLoop($w->getEventLoop())) {
// No loop handle (unexpected inside onWorkerStart): drain on a timer.
\Workerman\Timer::add(0.1, static function () use ($bridgeListener): void {
$bridgeListener->poll(64);
});
}
// Wrap — do not clobber — the worker-stop handler armed by
// ConnectionPool::armWorkerStopCleanup() (§4g, inherited pre-fork):
// DB-socket teardown must keep running.
$previousStop = $w->onWorkerStop;
$w->onWorkerStop = static function () use ($bridgeListener, $previousStop, $w): void {
if (is_callable($previousStop)) {
$previousStop($w);
}
$bridgeListener->close();
};
} catch (\Throwable $bridgeError) {
// Deferred-item #15: the message now names BOTH boots — this
// worker's instance and (when the winner stamped it) the
// owner of the path — so a same-checkout double-start is
// legible in the log channel instead of two daemons emitting
// the same anonymous warning.
$logger->error('[SyncPlayBridge] listener unavailable; REST mutations will not reach this worker live', [
'error' => $bridgeError->getMessage(),
'boot' => BootIdentity::describe(),
]);
}
}
// Build and configure the WebSocket server with the shared manager.
$wsConfigRaw = $config['websocket'] ?? null;
$wsConfig = is_array($wsConfigRaw) ? $wsConfigRaw : [];
$wsConfig['host'] = $wsConfig['host'] ?? '0.0.0.0';
$wsConfig['port'] = $wsConfig['port'] ?? 8097;
$wsConfig['stale_connection_timeout'] = $wsConfig['stale_connection_timeout'] ?? 300;
$wsConfig['stale_group_timeout'] = $wsConfig['stale_group_timeout'] ?? 3600;
// SV-4.7: thread the JWT secret so the WS server enforces handshake auth
// for privileged SyncPlay/dashboard/playback events. Sourced from the SAME
// JWT_SECRET the HTTP auth layer uses (config/server.php already resolves
// it); fall back to the env directly so a bare $wsConfig still gets it.
// Empty (JWT_SECRET unset — dev) → anonymous connections allowed.
$wsConfig['jwt_secret'] = $wsConfig['jwt_secret'] ?? (getenv('JWT_SECRET') ?: '');
// M35 (seceng2 M1): the handshake gate applies the SAME user-liveness
// law every HTTP request passes through RequestAuthenticator —
// active status (S1), deleted-account null (H-1), revocation watermark
// vs iat (M-1) — by routing the credential through this very
// AuthManager. The container is already authoritative in THIS fork
// (SyncPlayManager + snapshot wiring above resolve from it), and
// AuthManager's status probe is the 5s cached single-PK lookup HTTP
// uses — no new cache layer, no cadence invented here. Resolved
// UNWRAPPED on purpose: like the UserRepository get above, a
// container failure must fail the WS boot loudly rather than
// silently downgrading the auth gate to crypto-only.
/** @var \Phlix\Auth\AuthManager $wsAuthManager */
$wsAuthManager = $container->get(\Phlix\Auth\AuthManager::class);
// 2026-11-10 off-kit owner ruling: the query-carrier gate's admin-row
// tier (auth.ws_query_token) resolves live per handshake through the
// SAME container store the HTTP rails write, so an admin flip lands on
// the next query handshake without a restart. Resolved UNWRAPPED on
// purpose — the $wsAuthManager law above: a silently missing store
// would make the admin control quietly inert instead of failing the
// boot. This adds no new DB-read class to the rail: handshake-time
// store reads are already the shipped posture via AuthManager's
// 5s-cached liveness probe, and the extra single-row SELECT rides
// QUERY-carrier handshakes only (bearer/cookie legs never consult it).
/** @var \Phlix\Admin\SettingsRepository $wsQueryTokenSettings */
$wsQueryTokenSettings = $container->get(\Phlix\Admin\SettingsRepository::class);
/** @var array<string, mixed> $wsConfig */
// Inject THIS worker ($wsWorker) — the one that actually listens on :8097
// — so WebSocketServer binds its connection-lifecycle callbacks
// (onConnect / onMessage / onClose / onError / onWebSocketPong) onto the
// real accepting worker. Without this the callbacks would bind to a
// throwaway internal Worker created after Worker::runAll() (never listens),
// so the pool would stay empty and pongs would never reach recordPong() —
// breaking S-F28 half-open detection and SyncPlay routing in the resident
// path.
$wsServer = new \Phlix\Server\WebSocket\WebSocketServer(
$wsConfig,
$messageHandler,
$w,
$wsAuthManager,
$wsQueryTokenSettings,
);
$wsServer->setSyncPlayManager($syncPlayManager);
// SV-4.15(h): inject the per-IP WS-connect limiter so onWebSocketConnect
// throttles handshake attempts BEFORE the auth gate. The :8097 WS worker
// is count=1, so the worker-local in-memory RateLimitProfiles::WS_CONNECT
// limiter is already server-wide — no DB backend needed. Best-effort: a
// container hiccup must never stop the WS worker from listening, so a
// failed resolve just leaves the hook unthrottled.
try {
/** @var mixed $wsConnectLimiter */
$wsConnectLimiter = $container->get(RateLimitProfiles::WS_CONNECT);
if ($wsConnectLimiter instanceof RateLimiterInterface) {
$wsServer->setWsConnectLimiter($wsConnectLimiter);
}
} catch (\Throwable $e) {
LoggerFactory::get(LogChannels::WEBSOCKET)->warning(
'Failed to wire WS-connect rate limiter; connect hook unthrottled',
['error' => $e->getMessage()],
);
}
// S2 metrics: wire the metrics collector into the WS server.
/** @var MetricsCollector $wsMetricsCollector */
$wsMetricsCollector = $container->get(MetricsCollector::class);
if ($wsMetricsCollector->isEnabled()) {
$wsServer->setMetricsCollector($wsMetricsCollector);
}
// Trigger onStart to log the startup message and arm cleanup + ping
// timers (Timer::add registers into THIS worker process, so the reaper
// and ping sweeps run here). The connection-lifecycle callbacks
// (onConnect / onMessage / onClose / onError / onWebSocketPong) were just
// bound onto $w (this listening worker) by the WebSocketServer
// constructor above.
$wsServer->onStart();
// S2 metrics: arm the live-connection touch timer + the flush timer in the
// WS worker.
if ($wsMetricsCollector->isEnabled()) {
/** @var MetricsFlushService $wsFlushService */
$wsFlushService = $container->get(MetricsFlushService::class);
/** @var array{flush_interval_seconds?: int} $wsMetricsConfig */
$wsMetricsConfig = $config['metrics'] ?? [];
$wsFlushInterval = (int) ($wsMetricsConfig['flush_interval_seconds'] ?? 5);
// The WS worker is its own count=1 group, so $w->id is 0 — the SAME id
// as HTTP worker 0. That collides in the metrics_rollup PK
// (bucket_started_at, worker_id) and mislabels metrics_connections.worker_id.
// Namespace the WS worker above the HTTP id space (worker_id is SMALLINT,
// signed cap 32767; the HTTP pool is count=14 → ids 0-13).
$wsWorkerId = 10000 + (int) $w->id;
// Between flushes, push each live connection's current cumulative bytes
// into the registry so the live-connection panel shows real throughput
// for the whole connection lifetime (not a zero row until it closes).
// Touch at least twice per flush window so the flush always sees fresh
// totals when it computes per-connection rates.
$wsTouchInterval = max(1, intdiv($wsFlushInterval, 2));
\Workerman\Timer::add(
$wsTouchInterval,
static function () use ($wsServer): void {
$wsServer->touchActiveConnections();
},
);
\Workerman\Timer::add(
$wsFlushInterval,
static function () use ($wsFlushService, $wsWorkerId): void {
$wsFlushService->flush($wsWorkerId, (int) time());
},
);
}
/** @var \Phlix\Common\Logger\StructuredLogger $wsLogger */
$wsLogger = $container->get('logger.websocket');
$wsLogger->info('SyncPlay manager initialized', [
'message' => 'One SyncPlayManager instance handles all WS connections',
]);
};
} catch (\Throwable $e) {
// The WS worker is best-effort; never block the HTTP server.
trigger_error('Failed to set up WebSocket worker: ' . $e->getMessage(), E_USER_WARNING);
}
// -----------------------------------------------------------------------------
// 4b. Managed worker processes (1.1b).
//
// This app is hand-rolled (no Webman `support\App::run()`), so config/process.php
// is NOT auto-consumed by the framework — we read it here and spawn each enabled
// entry as a sibling Worker under the same Worker::runAll() process group, so
// `php start.php start` supervises HTTP + workers together (reload-able as one
// group). Additive + guarded: a failure building any worker must NOT take down
// the HTTP workers (they are separate processes), so the spawn loop is wrapped
// in try/catch and the per-worker container is built inside onWorkerStart (it
// cannot be built before fork).
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// 4c. Hub heartbeat worker.
//
// When the server is enrolled with a hub, it must POST periodic heartbeats
// so the hub keeps it marked online. The heartbeat is a Workerman\Timer
// armed by HubClient::startHeartbeatLoop(); a Timer only ticks inside a
// running worker, and a Worker created after runAll() never forks — so it
// MUST be declared here, before runAll(). count=1 so an enrolled server
// emits a single heartbeat stream (not one per HTTP worker). HubApplication
// ->start() is a no-op when there is no enrollment, so this is harmless on
// an unpaired server.
// -----------------------------------------------------------------------------
try {
$hubHeartbeatWorker = new Worker();
$hubHeartbeatWorker->count = 1;
$hubHeartbeatWorker->name = 'phlix-hub-heartbeat';
$hubHeartbeatWorker->onWorkerStart = static function (Worker $w) use ($config, $applyCuratedCoroutineHooks): void {
$applyCuratedCoroutineHooks();
// Effective-config overlay, mirroring the HTTP worker.
// {@see \Phlix\Config\EffectiveConfig}
$config = EffectiveConfig::bootstrapAndOverlay($config);
// Built inside the fork so the child owns its own DB/HTTP state.
$container = ContainerFactory::create($config);
/** @var \Phlix\Hub\HubApplication $hubApp */
$hubApp = $container->get(\Phlix\Hub\HubApplication::class);
$hubApp->start();
// If the server isn't enrolled yet, poll for an enrollment that appears
// later — i.e. when the operator pairs this RUNNING server — so the
// heartbeat loop starts without a process restart. The timer stops once
// the loop is running (or self-clears if the worker can't be set up).
if (!$hubApp->isRunning()) {
$retryTimer = null;
$retryTimer = \Workerman\Timer::add(15, static function () use ($hubApp, &$retryTimer): void {
if ($hubApp->isRunning()) {
if ($retryTimer !== null) {
\Workerman\Timer::del($retryTimer);
}
return;
}
if ($hubApp->isEnrolled()) {
$hubApp->start();
}
});
}
};
} catch (\Throwable $e) {
// The heartbeat worker is best-effort; never block the HTTP server.
trigger_error('Failed to set up hub heartbeat worker: ' . $e->getMessage(), E_USER_WARNING);
}
// -----------------------------------------------------------------------------
// 4c-bis. Background timer worker.
//
// Registers the periodic timers that were previously reachable ONLY from
// Application::run() — a CGI-era entry point this daemon never called, since
// excised dead (review minor 5; see the
// note at the top of section 4 and Application::startBackgroundTimers()). The
// omission meant automatic backups never ran, stats_storage was never written
// (blank dashboard Storage card), wedged transcodes never freed their slot, and
// the newsletter never sent.
//
// count=1 is REQUIRED, not an optimisation: these timers write backups and
// storage snapshots, so running them per-HTTP-worker would multiply both by the
// worker count.
// -----------------------------------------------------------------------------
try {
$backgroundTimerWorker = new Worker();
$backgroundTimerWorker->count = 1;
$backgroundTimerWorker->name = 'phlix-background-timers';
$backgroundTimerWorker->onWorkerStart = static function (Worker $w) use (
$config,
$applyCuratedCoroutineHooks
): void {
// Guard the whole body. The outer try/catch below only covers WORKER
// SETUP; anything thrown in here runs inside the forked child, where an
// uncaught throwable takes the process down and systemd restarts it —
// i.e. a crash-loop that would take the HTTP service with it. These
// timers are strictly best-effort: losing them degrades backups and
// dashboard stats, which is bad, but it must never cost availability.
try {
$applyCuratedCoroutineHooks();
// Effective-config overlay, mirroring the HTTP worker.
// {@see \Phlix\Config\EffectiveConfig}
$config = EffectiveConfig::bootstrapAndOverlay($config);
// Built inside the fork so the child owns its own DB state.
$container = ContainerFactory::create($config);
/** @var ConnectionPool $pool */
$pool = $container->get(ConnectionPool::class);
$timerApp = new Application($container, $config, $pool);
$timerApp->startBackgroundTimers();
} catch (\Throwable $e) {
// S433: an undelivered hook allowlist is NOT best-effort. It means
// this worker would run with the crash-prone SWOOLE_HOOK_ALL mask,
// so let it out — the child dies loudly (Workerman logs the
// non-zero exit) instead of trigger_error-ing past the mitigation.
if ($e instanceof \Phlix\Server\Runtime\HookDeliveryException) {
throw $e;
}
trigger_error(
'Background timer worker failed to start: ' . $e->getMessage(),
E_USER_WARNING
);
}
};
} catch (\Throwable $e) {
// Best-effort, exactly like the heartbeat worker: a timer-setup failure must
// never stop the HTTP server from booting.
trigger_error('Failed to set up background timer worker: ' . $e->getMessage(), E_USER_WARNING);
}
// -----------------------------------------------------------------------------
// 4d. Relay tunnel worker.
//
// When the server is enrolled with a hub and relay is enabled in config,
// this worker establishes the outbound WebSocket tunnel to the hub's relay
// worker (port 8802). Like the heartbeat, it must be declared before
// runAll() so the forked child owns its own connection state.
// -----------------------------------------------------------------------------
try {
// M20 (devops2 major 3): socket-less, like the hub-heartbeat worker above —
// `text://0.0.0.0:0` here used to bind a kernel-chosen ephemeral port on
// ALL interfaces for a worker that only needs the timer context.
$relayTunnelWorker = new Worker();
$relayTunnelWorker->count = 1;
$relayTunnelWorker->name = 'phlix-relay-tunnel';
// F-1c (the cure regime, host-proven on this estate's production build):
// this worker runs the Select event loop and is NEVER hooked. Workerman
// reads $worker->eventLoop inside the child before run() dispatches, so
// this per-worker override flips $eventLoopClass to Select in this
// process only — the coroutine dispatcher then takes the plain-Fiber
// branch and no Swoole hook is ever installed here (the master no longer
// pre-enables either; see the F-1c note at the top of this file). The
// mask matrix that forced this: per-bit SSL|TLS exclusion was measured
// INERT (the openssl swap is sticky once live), Select with inherited
// hooks crash-looped, and only Select + never-hooked drove the full TLS
// handshake to completion on the wire — the exact regime
// {@see \Phlix\Hub\TlsRelayConnection}'s pass-walk and F-1's original
// Select proof were written for. The relay loop was never
// coroutine-dependent: timers, the local TCP proxies and the state-store
// file I/O are all Select-native. The shared re-assert call below
// role-gates itself off for this worker (rule 6 in
// HookAllowlistEnforcementGuardTest); the corroboration gate, entry
// watchdog and HELLO_ACK deadline belts stay armed as the net.
$relayTunnelWorker->eventLoop = Select::class;
$relayTunnelWorker->onWorkerStart = static function (Worker $w) use ($config, $applyCuratedCoroutineHooks): void {
$applyCuratedCoroutineHooks();
// Effective-config overlay, mirroring the HTTP worker.
// {@see \Phlix\Config\EffectiveConfig}
$config = EffectiveConfig::bootstrapAndOverlay($config);
// Built inside the fork so the child owns its own DB/HTTP state.
$container = ContainerFactory::create($config);
/** @var \Phlix\Hub\RelayConfig $relayConfig */
$relayConfig = $container->get(\Phlix\Hub\RelayConfig::class);
/** @var \Phlix\Hub\HubClient $hubClient */
$hubClient = $container->get(\Phlix\Hub\HubClient::class);
/** @var \Phlix\Common\Logger\StructuredLogger $logger */
$logger = $container->get('logger.hub');
// S38/S39: cross-process state store — this relay fork is the SOLE writer
// of relay-tunnel.state.json (which the HTTP worker's health/admin
// surfaces read to reflect the real tunnel state), and the SOLE READER of
// relay-control.json (the operator kill-switch persisted by the admin
// relay Enable/Disable controls). Resolved here, before the disabled-gate
// below, so both the boot decision and the consumer share one instance.
/** @var \Phlix\Hub\RelayStateStore $relayStateStore */
$relayStateStore = $container->get(\Phlix\Hub\RelayStateStore::class);
$enrollment = $hubClient->loadEnrollment();
$serverId = $enrollment !== null ? $enrollment->serverId : '';
// Auto-enable the relay tunnel once the server is paired with a hub
// (P1): the presence of a stored enrollment is enough — no
// PHLIX_RELAY_ENABLED env var required. An explicit operator kill-switch
// still wins: either the PHLIX_RELAY_DISABLED env var OR the persisted
// relay-control.json flag written by the admin "Disable" control (S39).
// The persisted flag is why the toggle "takes effect on reload" — this
// fork re-reads it on every graceful reload.
//
// S39 (kill-switch fix): the decision lives in RelayTunnelBoot because
// this file runs OUTSIDE PHPUnit and could never be pinned inline. It
// used to gate ONLY withAutoEnable() below while $consumer->start() ran
// unconditionally, so with hub_relay_ws_url set the admin "Disable"
// control was a complete no-op. The gate now suppresses the tunnel and
// persists an honest disabled state for the health/status readers.
$relayDisabled = \Phlix\Hub\RelayTunnelBoot::isOperatorDisabled(
getenv('PHLIX_RELAY_DISABLED'),
$relayStateStore,
);
if (!\Phlix\Hub\RelayTunnelBoot::allowBoot($relayDisabled, $relayStateStore, $logger)) {
// Kill-switch set: honest state persisted above; build nothing else
// in this fork (no Application, no consumer, no tunnel).
return;
}
if ($enrollment !== null) {
$relayConfig = $relayConfig->withAutoEnable($enrollment->hubBaseUrl);
}
// Build the in-process HTTP dispatcher so HTTP_REQUEST frames route
// through the same local app routers the HTTP daemon uses. The
// Application constructor only registers routes/middleware (no timers),
// so building one here in the relay fork is safe and side-effect-free.
/** @var ConnectionPool $relayConnectionPool */
$relayConnectionPool = $container->get(ConnectionPool::class);
$relayApplication = new Application($container, $config, $relayConnectionPool);
// S238: the pre-router image endpoints (/api/v1/artwork/{id},
// /api/v1/users/{id}/avatar) are in NO route table, so without this the
// relay dispatcher 404s both and a relayed browse renders no posters and
// no avatars. Resolved eagerly HERE (both storages are just a directory
// string) so a container misconfiguration fails at fork boot rather than
// silently degrading back to that 404 on the first poster request.
/** @var \Phlix\Server\Http\FastPath\PreRouterFastPaths $relayFastPaths */
$relayFastPaths = $container->get(\Phlix\Server\Http\FastPath\PreRouterFastPaths::class);
$relayDispatcher = new \Phlix\Hub\RelayRequestDispatcher(
$relayApplication,
$container,
$relayFastPaths,
);
// Warm WebPortalRouter once in this relay fork for the same reason as the
// HTTP worker above: RelayRequestDispatcher::dispatch() resolves it
// LAZILY (RelayRequestDispatcher.php) for any /api/ path the Application
// router 404s, and concurrent HTTP_REQUEST frames relayed over the tunnel
// can race php-di's non-coroutine-safe `entriesBeingResolved` guard,
// producing the same spurious "circular dependency" 500. Resolving it
// here — once, before $consumer->start() begins consuming frames — caches
// it so later get() calls short-circuit on resolvedEntries. Non-fatal.
if ($container->has(\Phlix\Server\WebPortal\WebPortalRouter::class)) {
try {
$container->get(\Phlix\Server\WebPortal\WebPortalRouter::class);
} catch (\Throwable $e) {
LoggerFactory::get(LogChannels::HUB)->debug(
'WebPortalRouter warm-up skipped in relay fork (will resolve lazily)',
['error' => $e->getMessage()],
);
}
}
// The relay fork's RelayStateStore (resolved above, before the
// disabled-gate) is passed to the consumer so it can persist live tunnel
// state to relay-tunnel.state.json for the HTTP worker to read.
$consumer = new \Phlix\Hub\RelayConsumer(
$relayConfig,
$hubClient,
$logger,
$serverId,
null,
null,
static fn (\Phlix\Server\Http\Request $req): \Phlix\Server\Http\Response
=> $relayDispatcher->dispatch($req),
$relayStateStore,
// S301: server-side hub-user → server-user resolution. Resolved
// eagerly here (both deps are just a DB handle) so a wiring miss
// fails the fork boot rather than silently keeping every relayed
// request un-mapped.
$container->get(\Phlix\Hub\RelayIdentityResolver::class),
);
// SV-4.2 ([S-F23], X1): wire the segment-process registry so HTTP_CANCEL
// frames can kill any tracked on-demand encode. This relay fork's
// dispatcher ($relayApplication) launches segment encodes in THIS process,
// so they register into this same registry singleton. (Full request-keyed
// matching — registering encodes under the relay request id so a cancel
// finds them directly — is the deferred follow-up; today closeLocalConnection
// below triggers the HTTP poll-loop wait-timeout kill.)
/** @var \Phlix\Media\Transcoding\SegmentProcessRegistry $segmentRegistry */
$segmentRegistry = $container->get(\Phlix\Media\Transcoding\SegmentProcessRegistry::class);
$consumer->setSegmentProcessRegistry($segmentRegistry);
$consumer->start();
};
} catch (\Throwable $e) {
// The relay tunnel is best-effort; never block the HTTP server.
trigger_error('Failed to set up relay tunnel worker: ' . $e->getMessage(), E_USER_WARNING);
}
// -----------------------------------------------------------------------------
// 4e. Config-driven managed workers (1.1b): library-scan + plugin-auto-update.
//
// config/process.php is the single source of truth for these long-running
// pollers, but this hand-rolled start.php is NOT auto-consumed by Webman, so we
// read it here and spawn each ENABLED entry as a count-sized sibling Worker
// under this same Worker::runAll() group — supervised alongside HTTP, restarted
// as one group, and (critically) running under the service's LimitMEMLOCK +
// the curated coroutine hooks, so the scan loop no longer dies on Swoole's
// io_uring ENOMEM the way the standalone `scripts/run-library-scan-worker.php`
// does under a default RLIMIT_MEMLOCK. The standalone script remains an
// alternative for operators who isolate the worker; running both is safe
// because ScanJobRepository::claimNext() is an atomic single-claimer UPDATE.
//
// Earlier this spawn loop lived here but was dropped during the Swoole
// event-loop refactor, leaving the `library_scan_jobs` queue with nothing to
// drain unless an operator ran the standalone script by hand.
//
// WHERE `process.<key>.enabled` IS HONOURED — and why it is not honoured here.
// This loop runs in the MASTER, before Worker::runAll(), and Workerman cannot
// fork a new Worker group afterwards. It therefore CANNOT consult the
// `server_settings` overrides: doing so would (a) require a blocking DB read in
// the master whose connection would then be inherited across every fork, and
// (b) still not be re-evaluated by the admin "Restart server" button, which
// sends SIGUSR2 — a GRACEFUL RELOAD that re-forks children from this same,
// already-executed master. So the spawn decision stays on the config-file
// default (all five ship `enabled => true`), and the EFFECTIVE value is applied
// inside onWorkerStart below, where it IS re-read on every reload: a worker
// disabled by an admin override starts, logs, and idles without arming its
// poll timer. Net effect for the operator: toggling `process.<key>.enabled` in
// the admin UI takes effect on the next reload/restart, exactly as the schema's
// `"restart": true` flag promises. The one case this cannot cover is ENABLING a
// worker that `config/process.php` itself disables — no Worker was ever spawned
// to re-check — which is an operator editing the file on disk, not a UI action.
// That asymmetry, and the "one idle process per UI-disabled worker" cost, are
// stated in each key's admin-facing `helpText` in phlix-shared v0.26.0+ (locked
// by ServerSettingsSchemaTest), so the admin sees it at the switch rather than
// only here. Do not silently widen or narrow this gate without updating that
// text — the whole point is that the UI must not promise what this cannot do.
// -----------------------------------------------------------------------------
// Managed-worker key → its DI-resolvable class exposing `start(int $pollSeconds)`.
// The map lives in config/managed_workers.php (single source of truth) so a
// config/process.php entry can never be "enabled" without a spawner — the exact
// gap that previously left the media-asset + similarity queues undrained.
try {
/** @var array<string, class-string> $managedWorkerClasses */
$managedWorkerClasses = require __DIR__ . '/config/managed_workers.php';
/** @var array<string, array{enabled?: bool, count?: int, poll_seconds?: int}> $processConfig */
$processConfig = require __DIR__ . '/config/process.php';
if (is_array($processConfig) && is_array($managedWorkerClasses)) {
foreach ($managedWorkerClasses as $procKey => $workerClass) {
$settings = $processConfig[$procKey] ?? null;
if (!is_array($settings) || ($settings['enabled'] ?? false) !== true) {
continue;
}
$count = (int) ($settings['count'] ?? 1);
$pollSeconds = (int) ($settings['poll_seconds'] ?? 5);
$managedWorker = new Worker();
$managedWorker->count = $count > 0 ? $count : 1;
$managedWorker->name = 'phlix-' . $procKey;
$managedWorker->onWorkerStart = static function (Worker $w) use (
$config,
$applyCuratedCoroutineHooks,
$workerClass,
$procKey,
$pollSeconds
): void {
$applyCuratedCoroutineHooks();
try {
// Effective-config overlay, mirroring the HTTP worker.
// {@see \Phlix\Config\EffectiveConfig}
$config = EffectiveConfig::bootstrapAndOverlay($config);
// Honour an admin `process.<key>.enabled = false` override.
// See the block comment above for why this gate lives here
// (in the fork, re-evaluated on every graceful reload)
// rather than in the master's spawn decision.
$procSettings = EffectiveConfig::file('process')[$procKey] ?? null;
if (is_array($procSettings) && ($procSettings['enabled'] ?? true) !== true) {
LoggerFactory::get(LogChannels::MEDIA)->info(
'Managed worker disabled by settings override; idling',
['process' => $procKey, 'worker_class' => $workerClass],
);
return;
}
// Built inside the fork so the child owns its own DB/HTTP state.
$container = ContainerFactory::create($config);
/** @var \Phlix\Media\Library\LibraryScanWorker|\Phlix\Plugins\Catalog\PluginAutoUpdateWorker|\Phlix\Media\Markers\Detection\BackgroundDetectorWorker|\Phlix\Media\MediaAsset\MediaAssetWorker|\Phlix\Media\SimilarityWorker $managed */
$managed = $container->get($workerClass);
// F1 — plugin boot activation in the scan/job worker. The
// library-scan worker dispatches `MediaItemAdded` (→ enrich
// plugins musicbrainz/anilist) and runs metadata enrichment
// (→ SourceRegistry-registered omdb/anidb/myanimelist), so its
// fork must wire the enabled plugins into ITS own
// ListenerRegistry + SourceRegistry. Gated to `library-scan`:
// the other managed workers (media-asset, similarity,
// marker-detection, plugin-auto-update) dispatch none of the
// plugin events, so wiring plugins there would only hold dead
// instances. WIRE-ONLY + non-blocking; never blocks the fork.
if ($procKey === 'library-scan') {
try {
$container->get(\Phlix\Plugins\PluginLoader::class)->bootstrapEnabled();
} catch (\Throwable $pluginBootError) {
LoggerFactory::get(LogChannels::PLUGINS)->error(
'plugin boot activation failed (managed worker)',
['process' => $procKey, 'error' => $pluginBootError->getMessage()],
);
}
// F2b — the automatic, THROTTLED, BACKGROUND trigger for
// plugin metadata SOURCES (omdb/anidb/myanimelist). F2a
// wired SourceRegistry dispatch behind an opt-in flag but
// left NO production caller. This subscriber lives in the
// SAME worker that dispatches MediaItemAdded (this
// library-scan managed worker): on the event it only
// ENQUEUES (no HTTP — the scan path stays fast); a re-arming
// Workerman timer drains one item per tick and gap-fills it
// from the DUE sources OVER the scan-resolved metadata. With
// no source plugins enabled the registry is empty, so
// nothing is ever enqueued and behaviour equals pre-F2b
// (RULE 7). Wired AFTER bootstrapEnabled() so the enabled
// sources are already registered in THIS worker's
// SourceRegistry. Non-blocking; never blocks the fork.
try {
$container->get(\Phlix\Media\Metadata\Enrichment\BackgroundEnrichmentSubscriber::class)
->register($container->get(\Phlix\Common\Events\ListenerRegistry::class));
} catch (\Throwable $enrichBootError) {
LoggerFactory::get(LogChannels::MEDIA)->error(
'background enrichment subscriber wiring failed (managed worker)',
['process' => $procKey, 'error' => $enrichBootError->getMessage()],
);
}
// Smart-COLLECTION membership refresh.
// SmartPlaylistRefreshHandler was written for this and then
// never wired: its `register()` had NO caller anywhere in
// the repo, so a collection linked to a smart playlist
// never re-evaluated its stored membership
// (`collection_items`) after a scan. This is that missing
// hookup, and that is the ONLY behaviour it changes — a
// smart PLAYLIST has no stored membership; its rules are
// evaluated on request by SmartPlaylistController::preview().
//
// Gated to `library-scan` for the same reason as the block
// above, and for one more: THIS is the process that
// dispatches LibraryScanCompleted (MediaScanner runs under
// this worker; the HTTP LibraryController only ENQUEUES a
// job and returns 202). It must NOT be registered in an
// HTTP worker — a refresh is O(library size × linked
// collections) + O(playlists) blocking DB round-trips
// (ONE linked collection already walks the whole library
// in 500-row batches, then writes the membership diff;
// with no link at all it is still one cheap `collections`
// lookup per playlist, so the floor is `1 + N`, not 1)
// and would stall every concurrent connection on that
// worker.
//
// On the event the subscriber only ENQUEUES a library id
// (no I/O — the scan path stays fast); a re-arming
// Workerman timer drains ONE library per tick, which also
// coalesces the one-event-per-library-PATH that
// LibraryManager::scanLibrary() produces. Non-blocking;
// never blocks the fork.
try {
$container->get(\Phlix\Playlists\SmartPlaylistRefreshSubscriber::class)
->register($container->get(\Phlix\Common\Events\ListenerRegistry::class));
} catch (\Throwable $smartPlaylistBootError) {
LoggerFactory::get(LogChannels::MEDIA)->error(
'smart playlist refresh subscriber wiring failed (managed worker)',
['process' => $procKey, 'error' => $smartPlaylistBootError->getMessage()],
);
}
// Folder watching — the missing DRIVER for the block
// above's second event. FolderWatcher::checkForChanges()
// is the only emitter of LibraryUpdated and had NO
// caller anywhere in the repo, so that half of the
// subscriber was inert. (Its watch list was empty here
// too: FolderWatcher::watch() is called only from
// LibraryManager::createLibrary(), i.e. in an HTTP
// worker, and nothing repopulated it from the DB.)
// FolderWatchScheduler does both — registers the
// libraries, then polls them.
//
// Gated to `library-scan` for the same reason as the
// block above: PSR-14 dispatch is per-process, and the
// subscriber that listens for LibraryUpdated is
// registered only here.
//
// SHIPS OFF (config/folder_watch.php `enabled => false`).
// A change check is a full recursive stat() walk of a
// library, and filesystem calls are not coroutine-hooked
// in this runtime, so the walk blocks this worker's event
// loop — and this is the worker that drains the scan-job
// queue. When enabled, a tick touches at most ONE
// library, with one deliberate exception: a tick whose
// registration walk THREW also re-checks one library,
// so a library that can never be registered cannot
// starve the re-check phase for the others.
// start() itself does no filesystem or DB work;
// when disabled it arms no timer at all.
try {
$container->get(\Phlix\Media\Library\FolderWatchScheduler::class)->start();
} catch (\Throwable $folderWatchBootError) {
LoggerFactory::get(LogChannels::MEDIA)->error(
'folder watch scheduler wiring failed (managed worker)',
['process' => $procKey, 'error' => $folderWatchBootError->getMessage()],
);
}
} elseif ($procKey === 'metadata-write') {
// S87 — plugin boot activation in the METADATA-WRITE fork.
// The MetadataWriterRegistry is per-process resident
// state: an enabled writer plugin is registered by
// PluginLoader::enable() in whatever process ran the
// enable (typically an HTTP worker), so this fork's own
// registry starts EMPTY and every drained job would
// silently no-op without the boot re-attach. ONLY
// bootstrapEnabled() is wired here — unlike library-scan
// this worker dispatches no plugin events and enriches
// nothing; writers are invoked DIRECTLY by the worker from
// the registry it wires in its own process. Non-blocking;
// a boot failure leaves an empty registry (the queue still
// drains, every job a safe no-op) rather than killing the
// fork.
try {
$container->get(\Phlix\Plugins\PluginLoader::class)->bootstrapEnabled();
} catch (\Throwable $pluginBootError) {
LoggerFactory::get(LogChannels::PLUGINS)->error(
'plugin boot activation failed (managed worker)',
['process' => $procKey, 'error' => $pluginBootError->getMessage()],
);
}
}
// Arms a Workerman\Timer that polls runOnce() every $pollSeconds.
$managed->start($pollSeconds);
} catch (\Throwable $e) {
// Guard the fork: log and idle rather than exit, so a build
// failure can't put the worker into a tight re-fork loop.
trigger_error(
'Managed worker ' . $workerClass . ' failed to start: ' . $e->getMessage(),
E_USER_WARNING,
);
}
};
ConnectionPool::armWorkerStopCleanup($managedWorker);
}
}
} catch (\Throwable $e) {
// Best-effort; never block the HTTP server.
trigger_error('Failed to set up managed worker processes: ' . $e->getMessage(), E_USER_WARNING);
}
// -----------------------------------------------------------------------------
// 4f. DLNA/UPnP SSDP advertiser worker.
//
// The SsdpAdvertiser is a Workerman Worker that covers both halves of SSDP
// discovery: it multicasts an SSDP NOTIFY to 239.255.255.250:1900 every 30
// seconds (the passive half), and since S51 it also binds udp://0.0.0.0:1900,
// joins that multicast group and answers inbound M-SEARCH datagrams with a
// unicast response (the active half). Most control points discover by searching,
// so without the second half a TV would only find this server if it happened to
// be listening at the moment of an announcement.
//
// The listen socket uses SO_REUSEPORT and a bind failure is contained inside
// SsdpAdvertiser::listen(), so a port-1900 conflict costs M-SEARCH replies
// rather than the worker or the server.
//
// This worker must be instantiated BEFORE Worker::runAll() because Workerman
// cannot fork a Worker post-runAll(). count=1 is sufficient since SSDP
// announcements are broadcast network messages that don't need redundancy.
// -----------------------------------------------------------------------------
// WHERE `dlna.cds_enabled` AND `dlna.enabled` ARE HONOURED — and why the
// spawn decision is split from the runtime decision.
// Exactly the same reasoning as the `process.<key>.enabled` gate above: this
// runs in the MASTER, before Worker::runAll(), so it cannot consult the
// `server_settings` overrides without (a) a blocking DB read whose connection
// every fork would then inherit, and (b) still not being re-evaluated by the
// admin "Restart server" button, which sends SIGUSR2 — a GRACEFUL RELOAD that
// re-forks children from this same, already-executed master. So the spawn
// decision below stays on the config-file values, and the EFFECTIVE value is
// applied inside SsdpAdvertiser::onWorkerStart, where it IS re-read on every
// reload: an advertiser disabled by an admin override starts and idles without
// opening its multicast socket.
//
// S297 made the file gate consult BOTH switches via the same rule the worker's
// own runtime gate uses ({@see \Phlix\Dlna\SsdpAdvertiser::isEnabledForConfig()}).
// Before that only `enabled` was checked, so an install with the CDS disabled
// (the SHIPPED default) still forked an advertiser that idled for its whole
// life — while its Workerman listen socket held udp://0.0.0.0:1900 and squatted
// the port against anything else that wanted it. Now the file's `cds_enabled`
// (default FALSE) participates in the fork decision, with the same asymmetry
// as `enabled`: a file-disabled switch cannot be turned back on by an admin
// override without a full service restart, because no Worker was ever spawned
// to re-check. That asymmetry is stated in the settings helpText, so the admin
// sees it at the switch rather than only here.
try {
$serverConfig = is_array($config['server'] ?? null) ? $config['server'] : [];
$dlnaPort = is_int($serverConfig['port'] ?? null) ? $serverConfig['port'] : 8096;
/** @var array{enabled?: bool, cds_enabled?: bool} $dlnaFileConfig */
$dlnaFileConfig = require __DIR__ . '/config/dlna.php';
// Same two-switch rule the advertiser's runtime gate uses, evaluated on
// the FILE config (the master cannot read the settings store — see above).
// A file that does not return an array yields no advertiser at all —
// deliberately fail-CLOSED (pre-S297 it failed OPEN and forked an idle
// worker holding :1900), and loud, so a malformed file is not silent.
if (!is_array($dlnaFileConfig)) {
trigger_error(
'config/dlna.php did not return an array; DLNA SSDP advertiser will not be spawned.',
E_USER_WARNING
);
}
$dlnaSpawnEnabled = is_array($dlnaFileConfig)
&& \Phlix\Dlna\SsdpAdvertiser::isEnabledForConfig($dlnaFileConfig);
if ($dlnaSpawnEnabled) {
$dbConfigPath = is_string($config['db_config_path'] ?? null) ? $config['db_config_path'] : null;
$dlnaSsdpWorker = new \Phlix\Dlna\SsdpAdvertiser(null, $dlnaPort, $dbConfigPath);
$dlnaSsdpWorker->count = 1;
$dlnaSsdpWorker->name = 'phlix-dlna-ssdp';
}
} catch (\Throwable $e) {
// The SSDP advertiser is best-effort; never block the HTTP server.
trigger_error('Failed to set up DLNA SSDP advertiser worker: ' . $e->getMessage(), E_USER_WARNING);
}
// -----------------------------------------------------------------------------
// 4f-bis. NAT-PMP maintenance worker (RFC 6886 §3.3 renewals + §3.2.1 listener).
//
// The resident owner of the persisted `mapping_renew_at` deadline and of the
// UDP 5350 announcement socket — the two gaps PortForwardService documented as
// TODO(arch) until this owner decision (2026-10-01). One process, count=1:
// renewal is once-per-deadline bookkeeping against a state FILE, so a second
// copy would double-renew against the same gateway and race the JSON ledger.
// This worker is the ONLY daemon call site of PortForwardService::renewOnce()
// (the other is scripts/port-forward.php `renew`), which is what makes
// double-execution structurally impossible; the timer arming itself is
// idempotency-guarded inside NatPmpMaintenanceWorker::arm().
//
// Same spawn-gate split as §4f: the master cannot read the settings store, so
// the FORK decision uses config/port-forward.php's `port_forwarding.auto`
// (fail-CLOSED on a malformed file, loud); the EFFECTIVE value — admin
// `port-forward.*` overrides — is applied inside the fork via the container
// that builds the PortForwardService, and the worker's tick/announcements
// additionally self-gate on the persisted `enabled` flag. A port-forwarding
// install that never enabled anything costs this worker an idle 60s stat of a
// missing file; an operator disable() is respected (the nulls in state stop
// the renewal and mute the listener). A :5350 bind conflict degrades the
// worker to renewal-only mode inside its own listen() override — it can never
// crash the server.
//
// Blocking-IO: renewOnce()/autoConfigure() are bounded sync UDP exchanges
// running HERE, in a dedicated count=1 process, never in HTTP/WS/heartbeat —
// see docs/dev/BLOCKING_IO_EXCEPTIONS.md Exception 8.
// -----------------------------------------------------------------------------
try {
/** @var mixed $portForwardFileConfig */
$portForwardFileConfig = require __DIR__ . '/config/port-forward.php';
$portForwardSection = is_array($portForwardFileConfig)
&& is_array($portForwardFileConfig['port_forwarding'] ?? null)
? $portForwardFileConfig['port_forwarding']
: null;
if ($portForwardSection === null) {
trigger_error(
'config/port-forward.php did not return a port_forwarding array;'
. ' NAT-PMP maintenance worker will not be spawned.',
E_USER_WARNING
);
}
if ($portForwardSection !== null && ($portForwardSection['auto'] ?? true) !== false) {
$natpmpMaintenanceWorker = new \Phlix\Network\NatPmpMaintenanceWorker();
$natpmpMaintenanceWorker->count = 1;
$natpmpMaintenanceWorker->name = 'phlix-natpmp-maintenance';
$natpmpMaintenanceWorker->onWorkerStart = static function (\Phlix\Network\NatPmpMaintenanceWorker $natpmpWorker) use (
$config,
$applyCuratedCoroutineHooks
): void {
// Whole-body guard like §4c-bis: an uncaught throwable in a forked
// child crash-loops the process (and systemd restarts the service);
// NAT-PMP maintenance is strictly best-effort for availability.
try {
$applyCuratedCoroutineHooks();
// Built inside the fork so the child owns its own DB state,
// and so the admin `port-forward.*` overrides participate —
// container resolution is the single source for how a
// PortForwardService is configured (NetworkServicesProvider).
$config = EffectiveConfig::bootstrapAndOverlay($config);
$container = ContainerFactory::create($config);
$service = $container->get(\Phlix\Network\PortForwardService::class);
if (!$service instanceof \Phlix\Network\PortForwardService) {
throw new \RuntimeException('Container did not resolve PortForwardService');
}
$logger = null;
if ($container->has('logger.network')) {
$candidate = $container->get('logger.network');
if ($candidate instanceof \Psr\Log\LoggerInterface) {
$logger = $candidate;
}
}
$natpmpWorker->serve($service, $logger);
} catch (\Phlix\Server\Runtime\HookDeliveryException $e) {
// S433 doctrine: undelivered hook allowlist is NOT best-effort.
throw $e;
} catch (\Throwable $e) {
trigger_error(
'NAT-PMP maintenance worker failed to start: ' . $e->getMessage(),
E_USER_WARNING
);
}
};
}
} catch (\Throwable $e) {
// Best-effort, exactly like the SSDP advertiser: a maintenance-worker setup
// failure must never stop the HTTP server from booting.
trigger_error('Failed to set up NAT-PMP maintenance worker: ' . $e->getMessage(), E_USER_WARNING);
}
// -----------------------------------------------------------------------------
// 4g. Worker-stop DB cleanup.
//
// Close every DB connection inside onWorkerStop — which still runs in a
// coroutine — so coroutine-hooked PDO sockets aren't torn down at RSHUTDOWN
// outside coroutine context. Leaving them open fatals every worker on SIGTERM
// ("Uncaught Swoole\Error: API must be called in the coroutine" /
// "Couldn't execute method Error::__toString").
// {@see ConnectionPool::armWorkerStopCleanup()}
// -----------------------------------------------------------------------------
foreach (
[
$httpWorker,
$wsWorker ?? null,
$hubHeartbeatWorker ?? null,
$relayTunnelWorker ?? null,
$dlnaSsdpWorker ?? null,
$backgroundTimerWorker ?? null,
$natpmpMaintenanceWorker ?? null,
] as $stopCleanupWorker
) {
if ($stopCleanupWorker instanceof Worker) {
ConnectionPool::armWorkerStopCleanup($stopCleanupWorker);
}
}
// -----------------------------------------------------------------------------
// 5. Run
// -----------------------------------------------------------------------------
Worker::runAll();