Skip to content

fix(getenv): a variable that is not set answers false, not "" - #801

Merged
nahime0 merged 8 commits into
illegalstudio:mainfrom
Guikingone:fix/getenv-absent-returns-false
Aug 29, 2026
Merged

fix(getenv): a variable that is not set answers false, not ""#801
nahime0 merged 8 commits into
illegalstudio:mainfrom
Guikingone:fix/getenv-absent-returns-false

Conversation

@Guikingone

Copy link
Copy Markdown
Collaborator

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. Every "is this
configured" check is written on that separation — getenv($name) !== false
and elephc collapsed the two, so that test was true for every name. Silently: no
error, no warning, just the other branch taken.

Found by accident, which is the part worth reporting: a test program that was
supposed to fork ran entirely inside its child branch, and the first symptom
looked like a profiler failing to find any child processes. The symptom never
points at the cause.

$missing = getenv('DEFINITELY_NOT_SET');
var_dump($missing);            // php: bool(false)   elephc: string(0) ""
var_dump($missing !== false);  // php: bool(false)   elephc: bool(true)

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, overriding 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, gethostbyaddr, realpath 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, rather than asserted by a test that would pass
against its own bug.

Measured against php -n

php before after
getenv('ABSENT') bool(false) string(0) "" bool(false)
getenv('ABSENT') === false true false true
getenv('EMPTY') (set to "") string(0) "" string(0) "" string(0) ""
getenv('EMPTY') === false false false false
getenv($x) !== false ? … not taken taken not taken

Every getenv form now matches php -n byte for byte.

One caller-visible consequence

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 entire existing corpus
compiles. It surfaced in my own fixture while writing this, which is how I know
what it looks like.

On the tests

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

The leak test says explicitly what it does not cover, rather than implying it.

The same shape exists once more, and is deliberately not here

readline has the identical descriptor narrowing (checker string|false,
descriptor Str); a sweep of every builtin that overrides its result type found
no others. It is left alone on purpose: 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.

Two neighbours are also out of scope and worth their own issue: $_ENV and
$_SERVER are empty in CLI mode (no PATH, no argv; $_SERVER is
populated under --web), and getenv() with no argument and the local_only
second parameter are unimplemented — both loudly, as compile errors.

Suites

elephc lib 1488 · bins 1661 · error_tests 1437 · codegen callables 445 ·
ir_backend_smoke 257 · opcache_env_override 12. fmt and clippy clean.

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:eir Touches EIR definitions, lowering, validation, or passes. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:s Small pull request. type:fix Corrects broken or incompatible behavior. labels Aug 28, 2026
@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown

Greptile Summary

The PR restores PHP-compatible getenv() behavior by preserving the distinction between an unset variable and a present empty value.

  • Carries the checked string|false union through EIR and boxes the runtime result accordingly.
  • Copies present environment values into owned heap storage and updates ownership and allocation effects.
  • Updates generated documentation and adds native behavior, typing, and leak regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/builtins/system/getenv.rs Removes the narrowing override so the checked `string
src/codegen/lower_inst/builtins/system.rs Boxes the runtime pointer pair as an owned string or the exact PHP false value.
src/codegen_support/runtime/system/getenv.rs Preserves libc's unset-versus-empty distinction and copies present values into owned storage on both supported architectures.
src/ir/runtime_fn.rs Aligns getenv effects and fresh-result ownership with its new heap allocations.
tests/codegen/callables/constants_and_system.rs Adds end-to-end regressions for unset and empty values plus result and temporary-name ownership.
scripts/docs/elephc_builtins/registry.py Presents the checker-derived union accurately despite the neutral contract representation lacking unions.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A["PHP getenv(name)"] --> B["libc getenv"]
  B --> C{"Pointer is NULL?"}
  C -->|"Yes: unset"| D["Return null pair"]
  D --> E["Box PHP false"]
  C -->|"No: present"| F["Measure value length"]
  F --> G["Copy into owned heap string"]
  G --> H["Box PHP string"]
Loading

Reviews (7): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

`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.
@Guikingone

Copy link
Copy Markdown
Collaborator Author

CI caught a gate I had checked and misread: Builtins docs in sync. I had looked at what it covers and concluded my files were outside it — the paths it compares are docs/php/builtins*, docs/internals/builtins, the registry JSON and docs/php/compatibility.md, and I stopped there without asking whether MY change regenerates any of them. It does: dropping the eir_result_type override moves the generated page from Result type source: shared to checked.

One line in the page, one in the registry beside it. Regenerated with extract_builtins.py --render --force rather than hand-edited, since the gate re-runs the generator and compares; audit_builtins.py, gen_php_comparison.py, validate_site_compat.py and both docs unittest suites report no further drift.

The rest of that run was green (127 jobs); Build & Test is the aggregate gate, so it failed because this one did.

@nahime0

nahime0 commented Aug 29, 2026

Copy link
Copy Markdown
Member

Follow-up, not a merge blocker: #806 — Magician eval() still answers "" for an unset name (same polarity #801 fixed on AOT). Out of this PR and out of #802.

@nahime0

nahime0 commented Aug 29, 2026

Copy link
Copy Markdown
Member

Updated this PR and pushed commit 317cdf29a:

  • merged current main (d605d2829) into the branch;
  • marked getenv results as Fresh, so owned variable-name temporaries are released;
  • corrected its effects from READS_PROCESS | ALLOC_CONCAT to READS_PROCESS | ALLOC_HEAP;
  • added a heap-debug regression covering an owned strtr(...) name temporary;
  • corrected the stale OPcache test comment now that missing and empty environment values are distinct;
  • regenerated the builtin registry/internals documentation.

Local validation completed successfully:

  • cargo build
  • cargo test --test codegen_tests test_getenv_ (5 passed)
  • cargo test --lib builtin_runtime_calls_use_descriptor_result_representations
  • cargo test --test opcache_env_override_tests empty_env_value_is_treated_as_unset
  • builtin docs audit, site compatibility validation, and target-architecture EIR boundary audit
  • assembly comment check, cargo fmt --all -- --check, and git diff --check

The GitHub Actions matrix has restarted and is currently running.

@github-actions github-actions Bot added area:tooling-ci Touches CI, development tooling, Docker, or repository scripts. size:m Medium-sized pull request. and removed area:builtins Touches PHP builtin declarations or emitters. size:s Small pull request. labels Aug 29, 2026
@nahime0
nahime0 merged commit 20fa890 into illegalstudio:main Aug 29, 2026
131 checks passed
@Guikingone
Guikingone deleted the fix/getenv-absent-returns-false branch August 29, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:eir Touches EIR definitions, lowering, validation, or passes. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. area:tooling-ci Touches CI, development tooling, Docker, or repository scripts. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:m Medium-sized pull request. type:fix Corrects broken or incompatible behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants