Skip to content

Worker mode: dotenv-loaded env vars are missing when the container resolves %env(...)% #3

Description

@diolektor

In worker mode, the variables Dotenv::bootEnv() writes at worker boot are not there when the DI container resolves %env(...)%, so a Symfony app throws EnvNotFoundException on every request — including the first one served by a worker.

Traditional mode is unaffected (dotenv is booted per request there).

Because of #2 the failure is easy to miss: it surfaces as a 200 carrying a 22-byte Internal Server Error body from AbstractHttpRunner::emitFallback500().

Environment

oxphp/runtime v0.2.0
OxPHP server 0.10.0 (ghcr.io/oxphp/oxphp:0.10.0)
oxphp_sapi ext 0.1.0
PHP 8.5.9 ZTS, Alpine
App Symfony 8.1, extra.runtime: {"class": "OxPHP\\Runtime\\Runtime", "disable_dotenv": false}
Server config WORKER_MODE_ENABLED=true, ENTRY_FILE=index.php, PHP_WORKERS=1

Reproduced with SUPERGLOBALS_ENABLED both true and false.

What the app sees

EnvNotFoundException: Environment variable not found: "OPENROUTER_MODEL".
  in vendor/symfony/dependency-injection/EnvVarProcessor.php:224
  #0 vendor/symfony/dependency-injection/Container.php(383): EnvVarProcessor->getEnv('string', 'OPENROUTER_MODE…', Closure)
  #1 var/cache/dev/…/getAi_TraceableAgent_DocsService.php(53): Container->getEnv('string:OPENROUT…')

OPENROUTER_MODEL and DATABASE_URL both come from the project's committed .env.

Instrumented run

I patched Internal\Runner\HttpKernelRunner::handleRequest() and mounted it into the container:

protected function handleRequest(OxRequest $ox): void
{
    \error_log(\sprintf('START env=%d server=%d OPENROUTER_MODEL: ENV=%s SERVER=%s getenv=%s DOTENV_VARS=%s',
        \count($_ENV), \count($_SERVER),
        \var_export($_ENV['OPENROUTER_MODEL'] ?? null, true),
        \var_export($_SERVER['OPENROUTER_MODEL'] ?? null, true),
        \var_export(\getenv('OPENROUTER_MODEL'), true),
        \var_export(isset($_SERVER['SYMFONY_DOTENV_VARS']) || isset($_ENV['SYMFONY_DOTENV_VARS']), true)));

    $request = $this->builder->build($ox);
    \error_log(\sprintf('AFTER-BUILD env=%d OPENROUTER_MODEL=%s',
        \count($_ENV), \var_export($_ENV['OPENROUTER_MODEL'] ?? null, true)));

    $response = $this->kernel->handle($request);   // <- throws
    // …plus an END probe after terminate(), which never fires
}

Two requests, single worker:

START       env=39 server=45 OPENROUTER_MODEL: ENV='openai/gpt-4o-mini' SERVER=NULL getenv=false DOTENV_VARS=true
AFTER-BUILD env=39 OPENROUTER_MODEL='openai/gpt-4o-mini'
                                        <-- kernel->handle() throws EnvNotFoundException; END probe never reached
START       env=26 server=45 OPENROUTER_MODEL: ENV=NULL SERVER=NULL getenv=false DOTENV_VARS=false
AFTER-BUILD env=26 OPENROUTER_MODEL=NULL

So there are two distinct losses:

  1. $_SERVER is gone from the very first request. The dotenv copy in $_SERVER is replaced by request data before the handler runs (SERVER=NULL, server=45). Survivable on its own, because EnvVarProcessor reads $_ENV[$name] ?? $_SERVER[$name] ?? getenv($name).
  2. $_ENV loses the dotenv keys too. On request 1 they are still present right up to handle() (39 entries, SYMFONY_DOTENV_VARS set) and the lookup inside handle() still fails; by request 2 they are already gone before the handler starts — $_ENV is back to the 26 process-environment entries and SYMFONY_DOTENV_VARS is gone with them. I could not pin the exact instant inside handle() where request 1 loses them.

getenv() returns false throughout, since Dotenv runs with usePutenv(false) by default.

Framework-free data point

The same setup without a Symfony kernel — index.php returning an anonymous HttpKernelInterface that just dumps the arrays, .env containing MY_VAR=hello, extra.runtime = {"class": "OxPHP\\Runtime\\Runtime", "disable_dotenv": false}:

return new JsonResponse([
    'from_SERVER' => $_SERVER['MY_VAR'] ?? null,
    'from_ENV'    => $_ENV['MY_VAR'] ?? null,
    'from_getenv' => getenv('MY_VAR') ?: null,
    'SERVER_count' => count($_SERVER),
    'ENV_count'    => count($_ENV),
]);

worker mode, three consecutive requests:

{"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29}
{"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29}
{"from_SERVER":null,"from_ENV":"hello","from_getenv":null,"SERVER_count":44,"ENV_count":29}

traditional mode, same requests:

{"from_SERVER":"hello","from_ENV":"hello","from_getenv":null,"SERVER_count":49,"ENV_count":27}

So loss (1) reproduces standalone and is stable, while loss (2) only shows up once a Symfony kernel is handling the request — which is why the minimal script keeps working and a real app does not.

Why this matters for the adapter

SymfonyRuntime boots dotenv exactly once, in the constructor, and the adapter then keeps that process alive across requests. Anything the boot wrote into the superglobals is therefore boot-time state living in arrays that the worker soft-reset owns. A one-shot request never notices; a worker does, immediately.

Possible directions, none of which I have tested:

  • Snapshot the env the runtime booted with and re-apply it into $_ENV/$_SERVER at the top of each worker iteration (before handleRequest()), so per-request repopulation cannot erase it.
  • Or boot dotenv with use_putenv: true, since EnvVarProcessor falls back to getenv() — though putenv() under ZTS deserves its own thought.

Happy to test a patch against the real app (Symfony 8.1 site, ~20 routes, six locales) if that helps.

Related: #2 — the 200 masking every failure above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions