Ctrl K
ComunidadGuías…

start.php

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?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();