Skip to content

feat(env): getenv() answers the whole environment, and the superglobals carry it - #802

Open
Guikingone wants to merge 10 commits into
illegalstudio:mainfrom
Guikingone:feat/php-environment-superglobals
Open

feat(env): getenv() answers the whole environment, and the superglobals carry it#802
Guikingone wants to merge 10 commits into
illegalstudio:mainfrom
Guikingone:feat/php-environment-superglobals

Conversation

@Guikingone

Copy link
Copy Markdown
Collaborator

Stacked on #801. Its commit is the first here; review the last one alone, or
wait for #801 to land and the diff narrows to it by itself.

PHP's CLI SAPI hands a script three things elephc did not have:

php before after
getenv() (no argument) array<string,string> compile error the environment
getenv($n, true) works compile error accepted
count($_ENV) 59 0 59
count($_SERVER) 68 0 68
$_SERVER['argv'] present absent present

The superglobals existed but were seeded empty, which reads to any program
that looks like not set rather than like a gap.

Measured against php -n throughout, on macOS and in a Linux container: the
array and its count, a value containing = (the first one separates —
splitting on the last renames every variable whose value holds one), false for
a name that is not set, local_only, $_ENV == getenv(), $_SERVER's nine own
keys, and the five request superglobals staying empty as they are in PHP.

Decisions taken from measurement rather than from the manual

local_only is accepted and ignored, because that is what it means here: in
the CLI SAPI getenv($n) and getenv($n, true) agree for a shell variable, a
putenv one and PATH, and getenv() == getenv(null, true). There is no
environment separate from the process's for the flag to select.

The four path-shaped $_SERVER keys are $argv[0]. PHP names the script it
was handed; a compiled program has no script at run time, and the thing that
was invoked is the closest true answer rather than a fabricated path.

putenv does not reach $_ENV/$_SERVER, only getenv(). They are
snapshots taken before the program ran. PHP has the same asymmetry — checked,
because it is easy to get wrong in either direction — and it is asserted.

Seeding stays pay-for-use: only the names the source spells are emitted, which
is also what PHP's auto_globals_jit does, and for the same reason.

The environment is read live

The first design captured the envp handed to main, which avoids a platform
split. It was wrong in a way only putenv shows: libc may reallocate the entry
vector when a variable is added, so the startup pointer goes stale — and
elephc's own one-name getenv($n) goes through libc and saw the addition, so the
two halves of one builtin disagreed. Reading it live costs the split back
(environ on Linux, _NSGetEnviron() on macOS), which is the same shape as
errno elsewhere in this runtime. The startup capture is removed rather than
left behind looking load-bearing.

Three defects of mine on the way, all one shape

A convention assumed instead of read.

  • __rt_hash_set's x86_64 registers were guessed, and wrong — that would have
    corrupted the hash silently on Linux.
  • __rt_hash_new was called with no arguments, so it took whatever was in the
    capacity register and asked for garbage * 64 bytes. Its own docblock warns
    about exactly that.
  • The long one: the result was declared AssocArray while the value is a hash
    boxed in a Mixed cell, so count() read the cell's tag and answered 5 for
    a 65-entry environment. getdate declares Mixed for precisely this reason.
    The type is the representation, and losing array<string,string> is the price
    of the box.

Two existing tests pinned the old behaviour

One pinned a diagnostic PHP does not have: unset($_SERVER); $_SERVER = 5; is
legal PHP and elephc refused it. Its guard moves to $_GET, whose type still
makes the refusal observable, and the property it actually protects — unset must
not abandon global storage — is now measured directly, as a heap difference.

That measurement turned up a pre-existing leak, out of scope here: a boxed
assoc array from a builtin is never released. Confirmed against getdate(),
which leaks its 13 blocks the same way (allocs=15 frees=2) and predates all of
this. Same shape likely in stat(), localtime(), parse_url(),
ob_get_status(). Worth its own issue.

Suites

elephc lib 1488 · bins 1661 · error_tests 1437 · codegen misc 41 ·
constants_and_system 134; fmt and clippy clean. Verified on Linux in a
container, including the putenv case. A --web build still skips the seeding
entirely: it references no __rt_getenv_all and keeps its request prelude.

PHP separates a variable that is NOT SET from one set to the empty string:
`getenv()` answers `false` for the first and `""` for the second, and every
"is this configured" check is written on that separation — `getenv($n) !== false`.
elephc collapsed them, so that test was true for every name. Silently: no error,
no warning, just the other branch.

The information was never missing. libc already distinguishes the two — a
missing name gives NULL, an empty value gives a valid pointer to a zero-length
string — and `__rt_getenv` already returned a null pointer for the first case.
What discarded it was the descriptor, which overrode the checker's `string|false`
to a plain `Str` "for present and missing variables alike". The checker had it
right the whole time; the EIR result now carries the same union, and the lowering
boxes it through `box_owned_string_or_false_result`, the convention `ob_get_clean`
and a dozen others already use.

The found path copies the value out of the environment block first. That is a
contract requirement rather than an observed crash: the result is boxed as an
OWNED string, and anything later classifying that payload reads its heap header
eight bytes before a string the allocator never handed out. Removing the copy
leaves every test green, because a foreign free is range-rejected — so the reason
is written where the copy is instead of asserted by a test that would pass
against its own bug.

One caller-visible consequence, which is the honest price of an honest type:
assigning the result into a local already typed `string` is now a type error
(`cannot reassign $x from string to string|false`). A fresh local is unaffected,
and the whole existing corpus compiles.

`test_getenv_nonexistent` kept passing against this for as long as it existed:
`strlen(false)` and `strlen("")` are both 0, so it pinned the length and never
the distinction. Its comment now says so, and the new test uses `=== false` in
both directions on one program — the empty-but-set case being what a fix in the
wrong place breaks. Mutation-verified: dropping the boxing reproduces the
original output exactly, `unset:string ... idiom:taken`.

The same shape exists in exactly one other builtin, `readline` (checker
`string|false`, descriptor narrowed to `Str`). Left alone deliberately: it reads
a line rather than an environment variable, PHP strips its newline where `fgets`
keeps it, and EOF is signalled differently — measuring that is its own change,
and bundling it unverified with this one would make neither trustworthy.

`elephc` lib 1488 · bins 1661 · error_tests 1437 · codegen callables 445 ·
ir_backend_smoke 257 · opcache_env_override 12. Output diffed against `php -n`:
identical on every getenv form.
@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:m Medium-sized pull request. type:feature Introduces new user-visible behavior or capabilities. labels Aug 28, 2026
`Result type source: shared -> checked`, in the generated builtin page and the
registry the generator writes beside it. That is exactly what dropping getenv's
`eir_result_type` override means: the EIR result now comes from the checker's
`string|false` rather than from a shared override narrowing it to `Str`.

The CI gate that caught this regenerates and compares, so the fix is to run the
generator, not to edit the page. `audit_builtins.py` and `gen_php_comparison.py`
report no further drift.
@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown

Greptile Summary

The PR expands getenv() to support the complete process environment and initializes CLI environment/server superglobals on demand.

  • Adds zero- and two-argument getenv() forms with live environment enumeration.
  • Seeds CLI $_ENV and $_SERVER while preserving the separate web initialization path.
  • Updates builtin metadata, runtime lowering, platform emitters, tests, documentation, and the system-info example.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/codegen_support/runtime/system/getenv_all.rs Adds platform-specific live environment enumeration; the corrected x86_64 prologue preserves ABI stack alignment across nested calls.
src/superglobals.rs Adds pay-for-use CLI initialization of environment and server superglobals while retaining the web-mode ownership boundary.
src/builtins/system/getenv.rs Updates checking and result typing for the optional name and local-only parameters.
crates/elephc-builtin-contract/src/catalog_data.rs Expands the shared getenv contract to two optional parameters and a mixed result.
docs/php/builtins/filesystem/getenv.md Updates the generated public signature for the expanded getenv surface.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[PHP source references getenv or CLI superglobals] --> B[Compiler selects required runtime setup]
  B --> C{Requested surface}
  C -->|getenv name| D[Read one live environment value]
  C -->|getenv no arguments| E[Enumerate live process environment]
  C -->|CLI superglobals| F[Build startup environment snapshot]
  E --> G[Box associative array]
  F --> H[Seed ENV and SERVER]
  D --> I[Return string or false]
Loading

Reviews (8): Last reviewed commit: "docs(boxing): the readline helper took i..." | Re-trigger Greptile

Comment thread src/codegen_support/runtime/system/getenv_all.rs Outdated
Comment on lines 7046 to +7065
@@ -7056,7 +7062,7 @@ pub(crate) static CONTRACTS: &[BuiltinContract] = &[
arity_error: None,
returns: TypeSpec::Mixed,
by_ref_return: false,
summary: "Gets the value of an environment variable.",
summary: "Gets the value of an environment variable, or the whole environment.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Undocumented getenv surface change

This changes the public getenv signature and adds the no-argument array result without the required docs/php/ update or an example under examples/, leaving the new overload, parameters, and return behavior absent from the repository's user-facing guidance.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/elephc-builtin-contract/src/catalog_data.rs
Line: 7046-7065

Comment:
**Undocumented getenv surface change**

This changes the public `getenv` signature and adds the no-argument array result without the required `docs/php/` update or an example under `examples/`, leaving the new overload, parameters, and return behavior absent from the repository's user-facing guidance.

**Context Used:** CLAUDE.md ([source](https://github.com/illegalstudio/elephc/blob/main/CLAUDE.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

@Guikingone
Guikingone force-pushed the feat/php-environment-superglobals branch from a33980d to ecc8eaf Compare August 28, 2026 16:48
@Guikingone

Copy link
Copy Markdown
Collaborator Author

Rebased onto #801's docs fix, and regenerated for this PR's own signature change — getenv now documents (string $name = null, bool $local_only = false) in the public builtin index and its page, which is what the catalog entry here declares.

Same gate as #801, one level up: changing a builtin's parameters changes its generated documentation, and the CI gate regenerates and compares rather than trusting the committed page. Folded into the feature commit rather than added as a follow-up, so that commit is self-consistent.

@Guikingone

Copy link
Copy Markdown
Collaborator Author

Swept the prose docs for claims this makes false, and found four.

Three were plainly stale — docs/php/namespaces.md described $_SERVER and $_ENV as seeded empty in CLI builds (that empty seeding is exactly what this closes), and docs/php/system-and-io.md carried the one-parameter signature. docs/internals/the-runtime.md gains __rt_getenv_all and now says what __rt_getenv returns for a name that is not set.

The fourth was worse than stale, because it gave a reason rather than a rule. docs/compiling/cli-reference.md said an empty environment variable is treated as unset by the opcache override "because getenv() cannot distinguish the two". It can, as of #801. I checked the code rather than assuming a regression: the behaviour is unchanged, because that override reads its value through a (string) cast and an unset name and an empty one both arrive as "" after it. So the explanation needed correcting, not the rule — a doc that argues against its own code is worse than one that is merely behind.

All four pages are hand-written; the generator was re-run and left them alone, and no generated page drifted.

@github-actions github-actions Bot added area:magician Touches eval, include execution, or elephc-magician. size:l Large pull request. and removed area:codegen Touches target-aware assembly or backend lowering. size:m Medium-sized pull request. labels Aug 28, 2026
@Guikingone

Copy link
Copy Markdown
Collaborator Author

Both findings addressed in 86f6cf2b1c.

The x86_64 alignment is a real defect, and it was backwards from what the
comment claimed.
A call leaves rsp at 8 mod 16 on entry; the five pushes
bring it back to 0. The sub rsp, 8 then took it to 8 — misaligning
__rt_hash_new, __rt_str_persist and __rt_hash_set — under a comment reading
"keep rsp 16-byte aligned for the nested calls".

Removed rather than widened to 16: nothing used those eight bytes, so the padding
had no purpose beyond the alignment it was breaking. Prologue and epilogue are
five pushes and five pops, and the arithmetic is now written down so the next
reader can check it instead of trusting the comment.

Not observed as a crash — aarch64 is unaffected and the x86_64 shard is CI's, not
this machine's — so I am reporting it as a convention violation that leaves the
path at the mercy of any callee using aligned moves, not as something I saw fail.

On the documentation: docs/php/system-and-io.md and docs/php/namespaces.md
were updated earlier on this branch (3a81ee50ad), along with the generated
builtin pages. What was genuinely missing was the example, and you were right to
ask — examples/system-info/main.php already covered getenv() and stopped at
the one-argument form. It now shows the three forms, the false-versus-empty
distinction, $_ENV/$_SERVER, and the asymmetry where a later putenv()
reaches getenv() but not the snapshots. Its environment section is byte-identical
to php -n, which is how I know the asymmetry is PHP's and not ours.

…ls carry it

PHP's CLI SAPI hands a script three things elephc did not have: `getenv()` with
no argument answering the environment as an array, a `$_ENV` equal to it, and a
`$_SERVER` holding the same plus nine keys of its own. The first was a compile
error; the other two existed but were seeded EMPTY, which reads to any program
that looks like "not set" rather than like a gap.

Measured against `php -n` throughout, on macOS and in a Linux container: the
array and its count, a value containing `=` (the FIRST one separates — splitting
on the last renames every variable whose value holds one), `false` for a name
that is not set, `local_only`, `$_ENV == getenv()`, `$_SERVER`'s nine own keys,
and the five request superglobals staying empty as they are in PHP.

`local_only` is accepted and ignored, because measurement says that is what it
means here: in the CLI SAPI `getenv($n)` and `getenv($n, true)` agree for a shell
variable, a `putenv` one and `PATH`, and `getenv() == getenv(null, true)`. There
is no environment separate from the process's for the flag to select.

The four path-shaped `$_SERVER` keys are `$argv[0]`. PHP names the script it was
handed; a compiled program has no script at run time, and the thing that WAS
invoked is the closest true answer rather than a fabricated path.

Three defects of my own on the way, all the same shape — a convention assumed
instead of read:

`__rt_hash_set`'s x86_64 registers were guessed and wrong, which would have
corrupted the hash silently on Linux. `__rt_hash_new` was called with no
arguments at all, so it took whatever was in the capacity register and asked for
`garbage * 64` bytes — the exact failure its own docblock warns about.

The long one: the result was declared `AssocArray` while the value is a hash
BOXED in a Mixed cell, so `count()` read the cell's tag and answered 5 for a
65-entry environment. `getdate` declares `Mixed` for precisely this reason. The
type is the representation, and losing `array<string,string>` is the price of the
box.

The environment is read LIVE rather than from the `envp` handed to `main`. That
was the first design and it was wrong in a way only `putenv` shows: libc may
reallocate the entry vector when a variable is added, so the startup pointer goes
stale — and elephc's own one-name `getenv($n)` goes through libc and saw the
addition, so the two halves of one builtin disagreed. Reading it live costs the
platform split the startup pointer avoided (`environ` on Linux, `_NSGetEnviron()`
on macOS), which is the same shape as `errno` elsewhere in this runtime. The
startup capture is removed rather than left behind.

`putenv` does NOT reach `$_ENV`/`$_SERVER`, only `getenv()`. Measured, not
assumed, and asserted: they are snapshots taken before the program ran, and PHP
answers the same asymmetry.

Two existing tests pinned the old empty seeding. One of them pinned a diagnostic
PHP does not have — `unset($_SERVER); $_SERVER = 5;` is legal PHP and elephc
refused it — so its guard moves to `$_GET`, whose type still makes the refusal
observable, and the property it actually protects (unset must not abandon global
storage) is now measured directly as a heap difference. That measurement turned
up a pre-existing leak, confirmed against `getdate()`: a boxed assoc array from a
builtin is never released, `frees=2` either way. Not caused here and not fixed
here.

`elephc` lib 1488 · bins 1661 · error_tests 1437 · codegen misc 41 ·
constants_and_system 134; fmt and clippy clean. A `--web` build still skips the
seeding entirely: it references no `__rt_getenv_all` and keeps its request
prelude.
Three said the CLI superglobals are empty, which was the divergence this branch
closes: `$_SERVER` now carries the environment plus PHP's nine own keys, `$_ENV`
equals `getenv()`, and `getenv()`'s signature has both its optional parameters.
The runtime table gains `__rt_getenv_all` and says what `__rt_getenv` now
returns for a name that is not set — a null pointer, which is what becomes PHP
`false`.

The fourth was worse than stale, because it gave a REASON that is now wrong: the
opcache override page said an empty environment variable is treated as unset
"because `getenv()` cannot distinguish the two". It can, as of the previous
commit. The behaviour is unchanged — the override reads its value through a
string cast, and an unset name and an empty one both arrive as `""` after it —
so what needed correcting was the explanation, not the rule. A doc that argues
against its own code is worse than a doc that is merely behind.

These four pages are hand-written: the generator was re-run and left them alone,
and no generated page drifted.
…ication

The comment said "~60 entries, so this avoids a rebuild" beside a request for
256, which is four times what that reason asks for and 16 KB of table per call.
The table grows past 75% load, so 128 is what clears 60 entries without a
rebuild — the number the sentence was already describing.

Left over from bisecting the heap-exhaustion bug, where the capacity was raised
to rule out a mid-build reallocation. It was not the cause, and the raise was
never walked back.

Re-measured against `php -n` on both hosts: six fixtures identical, and the
Linux container answers the same, `putenv` case included.
…alse

`readline()` had two defects that hid each other. It kept the trailing newline,
which `__rt_fgets` supplies because that is right for `fgets` and wrong here; and
its descriptor narrowed the checker's `string|false` to a plain `Str`, so end of
input came back as `""`. A program reading lines could not tell an empty line
from the end of them, and `while (($l = readline()) !== false)` never terminated.

Measured against `php -n`, one call per input, five inputs: `"abc\n"` -> `"abc"`,
`"abc"` unterminated -> `"abc"`, `"\n"` -> `""`, nothing -> `false`, and
`"abc\r\n"` -> four bytes, because php removes exactly one `\n` and the `\r`
belongs to the line.

The ORDER between the two halves is the whole correctness argument, which is why
they are one helper rather than a strip followed by the existing
`box_stream_string_or_false_on_empty_result`. That helper reads an empty result
as end of input, which is correct for `fgets` — keeping the newline means an
empty line is one byte and only EOF is zero. `readline` strips, so stripping
first turns a line the user typed into zero bytes and reports EOF for it.
Mutation-verified: moving the EOF test after the strip turns the empty-line case
from `str0` into `false`.

`test_readline` had covered this call since it existed and could not see either
defect: it compares `trim($line)`, and trimming removes the newline whether or
not `readline` did. Its comment now says so, and the new test prints LENGTHS,
because `""` and `false` look alike when echoed and so does a trailing newline.

This was the last builtin narrowing a falsy union to a scalar — a sweep of every
`BuiltinResultType::Shared` override now finds none, and the EIR test that used
`getenv` and then `readline` as its narrowing example says that emptiness is the
point rather than hunting for a third.

Generated docs regenerated for the declaration change, `result_type: shared ->
checked`. `elephc` lib 1488 · bins 1661 · error_tests 1437 · codegen::io 666;
fmt and clippy clean.
…ate I never ran

CI went red on `Non-Codegen Tests`, both Linux architectures, on a parity test in
`elephc-magician`: it asserts the interpreter's declared registry matches the
static catalogue, and I had added `local_only` to `getenv` in the catalogue
without touching it.

The expectation is updated rather than relaxed, and gains the defaults alongside
the names — `phpversion` right above it already asserts both, and a parameter
that exists with the wrong default is a different function.

What actually failed here was my verification, not the change. Every suite I ran
was `-p elephc`; CI's `Non-Codegen Tests` runs the WHOLE workspace minus three
heavy binaries, so it sees `elephc-magician` and I did not. `cargo test
--workspace --lib` is the cheap gate that would have caught it, and it is now
green across all thirteen crates.

Running the full workspace under `nextest` locally is not that gate: it schedules
~7600 tests including compile-and-link ones in parallel and a dozen fail or time
out for that alone. Confirmed by replaying three of them alone — `var_export`,
`runtime_cache`, `macos_dead_strip` — all green in isolation, so the batch is the
artefact.
Review finding, and it is exactly backwards from what the comment claimed. A
`call` leaves `rsp` at 8 mod 16 on entry; the five pushes bring it back to 0. The
`sub rsp, 8` then took it to 8 — misaligning `__rt_hash_new`,
`__rt_str_persist` and `__rt_hash_set` — under a comment reading "keep rsp
16-byte aligned for the nested calls".

The padding is removed rather than widened to 16: nothing used those eight bytes.
Prologue and epilogue are five pushes and five pops, and the reasoning is written
down so the next reader can check the arithmetic instead of trusting the comment.

Not observed as a crash — aarch64 is unaffected and the x86_64 shard is CI's, not
this machine's. It is a convention violation that leaves the path at the mercy of
any callee that uses aligned moves.

Also from review: the new `getenv` surface reaches the docs AND an example.
`docs/php/system-and-io.md` and `docs/php/namespaces.md` were updated earlier on
this branch; `examples/system-info/main.php` now shows the three forms, the
false-vs-empty distinction, `$_ENV`/`$_SERVER`, and the asymmetry where a later
`putenv()` reaches `getenv()` but not the snapshots. Its environment section is
byte-identical to `php -n`.

Workspace `--lib` across 13 crates · bins 1661; fmt clean. Re-measured on Linux:
the whole environment surface answers as before, `putenv` case included.
@nahime0

nahime0 commented Aug 29, 2026

Copy link
Copy Markdown
Member

I reviewed this PR against the current state of main. First, it needs to be rebased onto main: #801 and several subsequent changes have landed in the meantime, so #802 is now CONFLICTING and is no longer correctly aligned with the base and contracts introduced by #801. After the rebase, the conflicts should be resolved, the generated documentation refreshed, and the three-target verification rerun.

In addition to the rebase, I reproduced three blocking issues:

  1. getenv(null, true) is valid PHP and should return the complete environment, but the backend treats it as a single-variable lookup and compilation fails with getenv name for PHP type Void. The “whole environment” mode is currently selected only when the instruction physically has zero operands. The named form getenv(local_only: true) should be covered as well.
  2. The shared contract now accepts 0–2 arguments, but the Magician/eval backend still accepts exactly one. For example, eval('echo is_array(getenv()) ? "array" : "not-array";'); terminates with Fatal error: eval() runtime failed; getenv($name, true) is also rejected under eval.
  3. Every call to getenv() leaks the complete environment hash. With --heap-debug, three calls under a minimal environment report allocs=18 frees=3 live_blocks=15 live_bytes=25032: the Mixed box is released, while the hash, keys, and values remain live. The ownership path needs to be completed, with a regression test under tests/codegen/runtime_gc/.

There are also two stale docblocks: the arity test still says that calling getenv() without arguments is an error, and the readline module preamble says Union(Str, Bool) while the implementation now uses Union(Str, False).

The published CI is green and the direct AOT tests pass, but they do not cover the three paths above. My current assessment is changes requested: first rebase onto main, then fix the three blockers and add the corresponding regression tests.

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

Labels

area:builtins Touches PHP builtin declarations or emitters. area:magician Touches eval, include execution, or elephc-magician. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:l Large pull request. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants