Skip to content

feat(core): finalize PHP streams implementation - #638

Draft
Guikingone wants to merge 305 commits into
illegalstudio:mainfrom
Guikingone:feat/streams-csv-signatures
Draft

feat(core): finalize PHP streams implementation#638
Guikingone wants to merge 305 commits into
illegalstudio:mainfrom
Guikingone:feat/streams-csv-signatures

Conversation

@Guikingone

@Guikingone Guikingone commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

PHP streams compliance audit and implementation. The branch landed in two waves: the
17 audited phases from the streams spec, then a resource-lifetime pass that replaced the
descriptor-as-identity model with a generation-safe registry and made resource identity,
closure and numbering match php-src everywhere — including across the eval() boundary.

Wave 1 — audited phases

Phase Description
P0.1 Per-context-handle options table + fopen(..., $context) restoration
P0.2 stream_socket_client/server full 7-arg/6-arg signatures with &$errno/&$errstr by-ref validation
P0.3 stream_selectpoll(2), removing the 64-fd ceiling (cap 256)
P0.4 stream_get_meta_data real wrapper_type/uri via per-handle tables (file, php, data, http, ftp, compress.zlib/bzip2)
P0.5 HTTP wrapper method/headers/content/redirects — confirmed already implemented
P0.6 file_get_contents/file_put_contents/readfile full signatures (offset, length, flags, context)
P0.7 fgetcsv/fputcsv full PHP 8.4 signatures (enclosure, escape, eol) + 5-state CSV parser
P0.8 True stream_filter_prepend with 2-slot filter chain (shift + insert at slot 0)
P0.9 PSFS_FEED_ME/PSFS_ERR_FATAL propagation from user stream filters
P1.9 User-registered filters listed in stream_get_filters()
P1.11 getaddrinfo replaces deprecated gethostbyname in DNS resolution
P1.12 %c and %x format specifiers in sscanf/fscanf
P1.16 stream_set_chunk_size effective in stream_get_contents
P2.1 8 historical aliases (set_file_buffer, socket_set_block, …)
P2.5 Residual constants (SEEK_*, STREAM_FROM_*, STREAM_OPTION_CHUNK_SIZE, STREAM_META_MODIFIED, STREAM_PF_INET6)
P2.7 stream_is_local based on scheme/wrapper_id instead of always true
$http_response_header Auto-populated superglobal via __rt_get_http_response_headers

Audit fixes from a 3-model jury: fgetcsv escape default back to "\\" (8.4, not 9.0),
stream_socket_client flags default 1 (STREAM_CLIENT_CONNECT), and STREAM_CLIENT_*
values corrected against php-src.

Wave 2 — resource identity, lifetime, and eval parity

  • Generation-safe resource registry. Streams, contexts and filters are registry
    slots with a generation, not raw descriptors, so a stale handle cannot resolve to a
    recycled descriptor.
  • PHP's resource numbering, exactly. 1/2/3 are the standard streams, 4 is the
    request's default stream context
    — created lazily at the first stream open of any
    kind, which is why stream_context_get_default() reports a lower id than a stream
    opened before it — and user resources start at 5. Ids are never reused.
  • Closed resources report Unknown at every display site (var_dump(),
    get_resource_type()), for fclose/pclose/closedir alike, while keeping their id.
  • Filters are real resources on a chain: built-in filter names resolve at run time,
    php://filter attaches through the same chain, and filters close with their stream.
  • Leaks closed: request resources and the registry itself are released at exit —
    previously every compiled program leaked the slot table whether or not it used streams.
  • Network robustness: SIGPIPE is ignored (a closed peer used to kill the process),
    the TLS attach path completes its handshake and honours ssl.verify_peer, and fread
    no longer truncates a stream at 64 KiB.
  • eval() parity: the eight stream aliases carry eval signature metadata, socket
    by-ref params are declared in both required forms, a host-closed handle reports
    Unknown inside eval(), and eval-created streams draw from the same id counter as
    compiled code.
  • fopen('php://stdout') now duplicates the descriptor, as php-src does. It used to
    hand back descriptor 1 itself, so fclose() on that handle closed the program's real
    standard output and every later echo was silently discarded while the process still
    exited 0.
  • Also fixed on the way: foreach over an array of resources (refused as
    stream argument PHP type Int), implode(',', [false, false]) (refused as
    array_push for PHP type False), the data: wrapper reported as data instead of
    PHP's RFC2397, and a transposed heap-magic word on the x86_64 stream TypeError path.

Verification

Local runs are macOS ARM64; Linux coverage is CI's, and this PR is the first time CI has
ever run on this branch — it was conflicting until now, so no workflow had been triggered.
That first run found two Linux-only defects, both fixed here:

  • bl _signal on the whole AArch64 arm. The leading underscore is a PLATFORM
    property, not an architecture one, so every Linux AArch64 program failed to link with
    undefined reference to '_signal'. Emitter::bl_c resolves it per platform.
  • __rt_resource_registry_teardown passed the slot array in rdi while
    __rt_heap_free reads its operand from rax. Every CLI program on linux-x86_64
    segfaulted at exit, AFTER printing correct output, which took the whole platform's
    Codegen/Eval Codegen matrices down. Reproduced in the repo's own x86_64 container
    on <?php echo 1; (run=139), fixed, and re-verified there (run=0). One shard went
    from ~672 failures to 4.

Also fixed after CI exposed them: stream_socket_client/server accepted one argument
more than PHP (a peername parameter PHP does not have), seven stale arity expectations
in error_tests, and a ?int inside an array literal that crashed
__rt_array_push_refcounted because a nullable union never reached the tagged-scalar arms.

Green locally: --lib 2422/2422 · -p elephc-magician 1134/1134 · error_tests 362/362 ·
runtime_gc 316/322 · runtime_gc::heap 16/16 · ir_backend_smoke_test + array suites
286/286 · eval_resource_id_tests 14/14 · resource_id_and_hash_context_tests 25/25 ·
builtin_parity_tests 32/32 · codegen::io::streams 362/363.

Two further fixes landed after the first CI round: the registry's initial slot array moved
from the heap to static storage — a program compiled with a small --heap-size used to die
at startup with Fatal error: heap memory exhausted, and every program allocated 528 bytes
it never needed (allocs=0 now under --heap-debug) — and a ?int inside an array literal
no longer appends its tagged immediate through the refcounted path.

Known red, characterized

  • TLS session registry (4 tests). These are the specification tests for migrating
    _tls_sessions off the raw-fd side table; the implementation is preserved on
    wip/tls-session-in-streamstate and still needs the ftps:// call sites, SNI string
    ownership, and PHP's refusal to re-enable crypto.
  • User filters are not chain nodes (2 tests). FilterState already carries a
    php_user_filter slot and __rt_filter_create already accepts one, but
    lower_user_stream_filter_attach still routes a genuinely user-registered filter to the
    legacy per-descriptor table, and the chain applier skips nodes whose built-in id is 0
    ("user filters are applied by the bucket path"). So a user filter has no registry slot:
    it stays is_resource() === true after its stream closes, and stream_filter_remove
    detaches it unconditionally instead of honouring PSFS_ERR_FATAL from the closing
    flush. Finishing that migration is the remaining half of the filter work.
  • fopen(..., $context) through an untyped parameter. An untyped parameter is seeded
    PhpType::Int by the checker, so function f($c) { return $c; } reports int for a
    resource. Pre-existing and documented in fopen_core.rs; accepting Int in the emitters
    was tried before and reverted (it trades a clear diagnostic for a runtime throw), so the
    fix belongs in the checker.
  • Four Linux-only stream behaviours: stream_select wrapper-stream cast readiness, a
    dynamic ftp:// URL through file_get_contents, fpassthru capture under output
    buffering, and PSFS_PASS_ON control from a user filter. All four pass on macOS, so they
    are only observable through this PR's CI.

Docs

CHANGELOG.md gains the user-facing entries for both waves. docs/php/streams.md
documents the id model, the duplicated php:// descriptor, and the remaining reported-name
differences (stream_type for php://memory/temp/output and data:, the empty uri
for tmpfile(), literal-only php:// resolution). docs/php/eval.md documents the shared
numbering space. The 962 generated builtin pages were regenerated from the registries.

Merge

origin/main is merged in (15 commits: parse_url #667, by-ref foreach #648). Every
conflict was a generated builtin page — 152 of them, no source conflict — resolved by
regeneration rather than by picking a side.

@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. area:types Touches type checking, inference, or compatibility. 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. labels Jul 29, 2026
@Guikingone Guikingone changed the title feat: PHP streams compliance audit — 17 phases delivered feat: PHP streams compliance audit Jul 29, 2026
@Guikingone Guikingone self-assigned this Jul 29, 2026
@Guikingone
Guikingone force-pushed the feat/streams-csv-signatures branch from 6a229f2 to f133b17 Compare July 29, 2026 08:21
@Guikingone Guikingone closed this Jul 29, 2026
@Guikingone Guikingone reopened this Jul 29, 2026
@Guikingone
Guikingone force-pushed the feat/streams-csv-signatures branch from f133b17 to 70bc7a4 Compare July 29, 2026 08:44
@github-actions github-actions Bot added size:xl Very large pull request that needs deliberate review planning. and removed size:l Large pull request. labels Jul 29, 2026
@Guikingone
Guikingone force-pushed the feat/streams-csv-signatures branch 2 times, most recently from e6b27a2 to 239ea4a Compare July 29, 2026 11:43
@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Too many files changed for review (1168 files, 100 file limit).

@github-actions github-actions Bot added area:magician Touches eval, include execution, or elephc-magician. and removed area:types Touches type checking, inference, or compatibility. labels Aug 7, 2026
@nahime0
nahime0 self-requested a review August 8, 2026 14:18
`stream_tls_registry` is 8/8. The fixed `_tls_sessions` table — 2048 bytes indexed by
raw descriptor — is gone from the emitted assembly entirely, so a program is no longer
capped at 256 concurrent TLS streams and a freshly opened socket can no longer inherit
the session of a closed one through a reused descriptor number.

PHP-visible streams keep the session on their StreamState and reach it through the
opaque handle (`__rt_stream_tls_session` / `__rt_stream_set_tls_session`), which a
reused descriptor cannot address. `__rt_fwrite` takes a handle like `__rt_fread`, so
the filtered-write path no longer resolves a descriptor of its own.

FTP is deliberately NOT adopted into the resource registry. Its control and data
sockets are internal and never exposed as PHP resources; adopting them would mint
resource ids and shift the php-src-aligned numbering that this branch pins in 24 tests.
The `__rt_ftp_*` helpers are synchronous, so at most one control and one data
connection are live at a time and two words hold their sessions — no ceiling, and no
descriptor-keyed aliasing.

The 257-session test was also capped by its own harness: a probe worker completes its
handshake and then blocks reading, while the program opens all 257 streams before
writing to any of them, so a pool of 16 stalled the 17th handshake and pinned the
result at 16 regardless of the compiler. The pool now covers every simultaneously held
connection.

Verified: 705/706 across stream/filter/fwrite/fread/fopen/fclose/ftp/crypto/socket —
the one red is the separate --web request-reset regression.
A `--web` worker died on the request after one that registered a stream wrapper, and
on the request after one that opened more than eight resources. `web_tests` is 49/49
again, and the streams sweep is 850/850.

`__rt_web_reset` resets the PHP heap arena to pure-bump state between requests, on the
premise that nothing in the arena legitimately survives a request. Two registries broke
that premise: they keep their storage in the arena but reach it through
process-lifetime pointers, which the wipe left dangling.

- The userspace stream-wrapper tables (`_user_wrappers_ptr`, its flags and its open
  handles). `stream_get_wrappers()` then read a garbage slot count and allocated until
  the heap was exhausted. Forgetting the registration is also PHP's behaviour: a
  wrapper registered during a request is not visible to the next one.
- The resource registry's slot array, which starts in a static 8-slot block and GROWS
  onto the arena. Under eight resources the static block kept the symptom hidden; past
  that, the next request walked freed slots. The previous request's epilogue has
  already released every resource, so nothing leaks by forgetting the storage —
  `__rt_resource_registry_init` re-seeds from the static slots, and ids restarting per
  request is what PHP does.

The arena reset arrived with c4864ef; the dangling pointers predate it and were
simply unreachable while the arena persisted. Neither registry was covered for reuse
across requests, which is why the suites stayed green until the two met.
Neither defect the previous commit fixed was covered: the suites exercised wrapper
registration and resource opening only within a single request, so the dangling
pointers were never read. Both tests fail without that fix (verified by disabling it)
and pass with it, alongside the pre-existing request-reset test.

Also records the TLS session move and the worker crash in the changelog.
A `--web` request that left `ob_start()` open returned nothing, and every later request
served by the same worker returned nothing either: the nesting level leaked, so the
next response was captured into a buffer nobody flushed. The worker stayed alive, which
is why nothing reported it.

PHP flushes whatever remains open at request shutdown, and elephc's CLI epilogue
already did — only the `--web` epilogue skipped the drain. It now runs it in the same
position and for the same reason: user output handlers must run while locals and
statics are still alive.

`__rt_web_reset` also drops any leftover nesting level. The buffer payloads are arena
allocations reached through process-lifetime state, so this keeps the "nothing in the
arena survives a request" invariant true by construction rather than by the epilogue
having succeeded — the same failure shape as the two registries in 46ee4d6.

Found by auditing the rest of that family: every `.comm *_ptr` filled from the arena
and never reset. `_stream_default_context_handle` is already cleared by the request
reset; the remaining hits are static buffers, counters and function pointers.
`file_get_contents($url, false, $context)` ignored its context entirely: the lowering
never read the operand, so the http wrapper took its options from whichever context was
published last and every request went out as a GET. Verified against php 8.5.6, which
answers POST/PUT/GET/POST for the default/explicit/empty/null precedence — elephc now
matches, and a context passed to one read no longer leaks into the next.

`fopen()` already published its context around the open; its scope helpers are now
shared rather than duplicated, so both take the same path. The scope restores the
previous bridges afterwards, which is what keeps consecutive reads independent.
`readfile("http://…")` produced an empty body and `false`: the lowering only ever
opened its argument as a filesystem path, so no wrapper ever saw it. It now falls back
to the reader `file_get_contents()` uses when the direct open reports its -2
open-failure sentinel, which keeps ordinary files on the streaming path instead of
buffering them whole, and reuses the existing scheme matching rather than duplicating
it.

`readfile()` also publishes its `$context` for the duration of the call, like
`fopen()` and `file_get_contents()`. Verified against php 8.5.6: `GET` then `PUT`.
…ile calls did not spend a resource id

Two defects that hid each other, both found while checking `stream_socket_pair()`
against `php -n` 8.5.6.

`var_dump()` of a resource on its own was right, but the same resource inside a
container printed `NULL`. It is not a socket-pair bug: `var_dump([$fh])`,
`['h' => $fh]` and `[1, $fh, "s"]` all fail identically, because
`__rt_var_dump_value` routed runtime tag 9 to its NULL arm — the code said so:
"tag 8 null / 9 resource -> NULL". So a live socket pair rendered as
`[NULL, NULL]`. One renderer serves every container, so one arm fixes them all:
tag 9 now reaches `__rt_var_dump_emit_resource_line`, which composes
`resource(N) of type (T)` from `__rt_resource_id_of` and
`__rt_resource_type_name` at the current indent.

The NUMBER was wrong too. php's `file_get_contents()` and `file_put_contents()`
open a stream internally, so each call spends one PHP-visible resource id even
though the caller never sees a handle. elephc reads and writes those files with
raw syscalls and spent none, so every id after such a call was one lower than
php's — visible through `var_dump($handle)`, `(int) $handle` and
`get_resource_id()`. `__rt_resource_id_burn` advances the cursor by one; the
cursor is never reused, which is why advancing it is the whole of what php does
(php does not hand the id back when its internal stream closes either).
…kopen() could not name a transport

Four defects on the socket surface, all measured against `php -n` 8.5.6.

`stream_get_meta_data()` on any socket reported `wrapper_type => plainfile`, a
key php does not write at all. php reaches every transport through
`php_stream_xport_create`, which never assigns `stream->wrapper`, and
`_php_stream_get_metadata` writes the key only `if (stream->wrapper)`. elephc
left the wrapper id at its unset value 0, which the literal table maps to
"plainfile", so a socket pair, a `stream_socket_server()` and a
`stream_socket_client()` all claimed the plain-files wrapper had opened them.
The key is now skipped for a handle carrying a transport.

The `uri` moved the other way: php stores the address in `stream->orig_path` and
reports it, and elephc recorded the transport but never the text, so the key php
provides was MISSING. `__rt_stream_record_transport` now persists the address it
already receives. A socket pair and an accepted connection name no address, php
leaves `orig_path` NULL for them, and the key stays absent — which is why a null
address short-circuits instead of storing an empty string.

`fsockopen()` required a port. php's `$port` defaults to -1, which is what lets
`fsockopen("unix:///tmp/s.sock")` name a socket that has no port — so that call
did not compile. Worse, the runtime prepended `tcp://` unconditionally, so once
it did compile the address became `tcp://unix:///tmp/s.sock`, which resolves as a
HOSTNAME and died in getaddrinfo. php hands the address to
`php_stream_xport_create`, which reads the transport out of it and falls back to
TCP only when there is no `://`. The prefix is now conditional, `:port` is
appended only `if (port > 0)` as php does, and the descriptor records its
transport and `uri` like the other openers — php's `uri` here is the string it
composed, `host:port`, with no scheme, because the `tcp://` is elephc's and php
never saw it.

Last, the failure text dropped its port suffix for exactly the call above.
Which wording carries a port is decided by the KIND, not the port VALUE: php's
fsockopen template is `Unable to connect to %s:%d`, so the port prints whatever
it is, including the -1 default (`...s.sock:-1`), while the other two templates
name no port at all.
…ine-break-chars and binary did nothing

php's built-in stream filters DO read `$params`: `convert.base64-encode` and
`convert.quoted-printable-encode` take `line-length` and `line-break-chars`, and
the quoted-printable pair also takes `binary`. elephc retained `$params` only for
a USER filter, where `filter()` reads it off the instance, and passed 0 for a
built-in — so

    stream_filter_append($h, "convert.base64-encode", STREAM_FILTER_WRITE,
                         ["line-length" => 8]);

produced one unbroken line where php produces wrapped ones, and
`["binary" => true]` left SPACE and TAB literal where php escapes them.

The array is now parsed ONCE, at attach, into four plain words on the filter
node — which is where php parses it too, in each filter's `create` callback, and
for the same reason: none of the three can change while the filter is attached,
so probing the hash per buffer would put a lookup in the encoder's inner path for
a constant. The parsed values reach the encoders through globals rather than a
fifth argument, because `__rt_apply_stream_filter` is a leaf with no frame whose
scratch registers are already spoken for; the chain publishes them per node and
clears them immediately after, so a `line-length` cannot leak into the legacy
per-descriptor path that still serves `zlib.*` and `bzip2.*`.

Defaults needed no sentinel: measured on `php -n` 8.5.6, NEITHER encoder wraps
unless a `line-length` is named, so the zeroed node already says what php does
and the common path pays one compare. What did need measuring is the default
break — it is CRLF, not a lone newline: `["line-length" => 8]` over "hello world"
answers 18 bytes, not 17.

base64 wrapping is a post-pass over the encoded bytes, but quoted-printable is
not, and cannot be. php's break there is a SOFT break: a `=` that occupies a
column of its own and must never fall inside an `=XX` triplet. Only the encoder
knows where the triplets are, so the budget test lives inside its loop, before
each token: with `lbl` columns used and a token needing `n`, php breaks when
`lbl + n > line-length - 1`. That rule was derived from a 12-case matrix of
line lengths against escape positions and then checked against a 51-case one; a
`line-length` of 10 over `aaaaaa\xE9bbbbbb` keeps the escape on the first line,
and a `line-length` of 8 breaks before it.

Also found while probing and NOT fixed here, because it is a separate surface:
`str_replace()` with array arguments is refused by the EIR backend
("str_replace string coercion for PHP type Array(Str)").
…ence

php's four `convert.*` filters parse `$params` as an ARRAY and reject anything
else: `stream_filter_append($h, "convert.base64-encode", STREAM_FILTER_WRITE,
null)` raises `Stream filter (convert.base64-encode): invalid filter parameter`,
then `Unable to create or locate filter "convert.base64-encode"`, and answers
`false`. elephc attached a working filter and said nothing.

The refusal is theirs alone. Measured across ten filters on `php -n` 8.5.6,
`string.toupper`, `string.tolower`, `string.rot13`, `dechunk`, `zlib.deflate`
and `zlib.inflate` accept a null, an int or a string without complaint — they
never look at `$params`. That is why the test is a range over the four
contiguous `convert.*` ids rather than a blanket type check.

Omitting the argument is not the same as passing null, and the distinction is
php's, not an accident: php tests the zval POINTER, which is NULL only when the
argument was not supplied — so the three-argument call succeeds on the very
filters that refuse an explicit `null`. The attach lowering keeps the two apart
by retaining nothing when the call has three operands, which leaves the node's
params slot at 0 and makes "absent" distinguishable from "null" at run time.

The second warning uses php's OTHER verb: "Unable to create or locate filter"
when the filter exists but refused its parameters, against the plain "Unable to
locate filter" the codebase already emits for a name that resolves to nothing.
…that is not a filter

`stream_filter_remove($stream)` on an ordinary stream answered `true`. The chain
lookup rejected the handle, the call fell through to the legacy per-descriptor
path, cleared four already-empty table slots and reported that it had removed a
filter. php throws there — and again for a filter that was already removed, which
took the same route.

Its wording is not a variation on the generic one: php says `supplied resource is
not a valid stream filter resource`, with no argument name, for EVERY resource it
will not accept, and reserves the `Argument #1 ($stream_filter) must be of type
resource` form for a value that is not a resource at all. Measured on `php -n`
8.5.6 across a live stream, a closed stream, a removed filter, an int, a string
and a null.

The legacy path is not removed, only guarded: it still owns the per-descriptor
filters `zlib.*` and `bzip2.*` attach, so the refusal fires only when all four
direction slots for the descriptor are empty — which is exactly the case where
there was never a filter to remove.

Also measured while probing and NOT fixed here, because it is a different
mechanism: `stream_filter_append($h, "zlib.deflate", ...)` answers something that
is not a resource at all (`is_resource()` false, `get_resource_type()`
"Unknown"), where php answers a live `stream filter`. The zlib and bzip2 attaches
emit an inline filter shape rather than a chain node, so they mint no resource.
…t was not a resource

`stream_filter_append($h, "zlib.deflate", STREAM_FILTER_WRITE)` answered a value
`is_resource()` called false and `get_resource_type()` called "Unknown". php
answers a live `stream filter`. The same held for `zlib.inflate`,
`bzip2.compress`, `bzip2.decompress` and every `convert.iconv.*`.

The cause is how they are compiled: those five filter through code emitted over
the DESCRIPTOR rather than through a chain node, so the filtering worked and
there was simply no resource to hand back. Nothing observed their lifetime
either — neither `stream_filter_remove()`, which php lets close them, nor the
invalidation php performs when the owning stream closes.

Each now mints an INERT chain node: no built-in id and no `php_user_filter`,
which is already enough for the chain applier to pass it by while the inline
shape keeps doing the work. It joins the chain all the same, and that is the
point — the chain sweep is what closes it on `fclose()`, so `is_resource()` on a
filter whose stream has gone answers false, as php's does.

Measured on `php -n` 8.5.6 across the whole lifecycle: attach, remove, and close.

Found while probing and NOT fixed here, because it predates this change (verified
by disabling the new node and re-measuring): `zlib.deflate` over `php://memory`
writes NOTHING — 400 bytes in, an empty stream out. The same names are also
unreachable through a variable: `stream_filter_append($h, $name, ...)` with
`$name` holding "zlib.deflate" warns `Unable to locate filter`, because these
five are matched as compile-time literals.
… and flushed its tail too late

`stream_filter_remove()` on one of the filters compiled as an inline shape
reported success and left the shape RUNNING: the stream went on compressing, so a
following `fwrite("plain text here")` landed as deflate output. Those five —
`zlib.*`, `bzip2.*` and `convert.iconv.*` — filter through code keyed on the
DESCRIPTOR, not through the chain node that now carries their resource identity,
so unlinking the node only retires the resource. Removal now clears the
per-descriptor tables as well, which is what actually stops them.

The ORDER is the other half. php flushes the encoder's tail when the filter is
REMOVED, so the two-byte deflate sync marker precedes the plain text that follows
it; elephc emitted the tail at `fclose()` and put the same bytes out back to
front. The three flush helpers invoked here are the ones `fclose()` already uses,
and each skips a descriptor that has no such filter, so a filter that is none of
them pays three loads.

Measured on `php -n` 8.5.6: after removal a memory stream holds the 8 bytes php
holds, and a file holds `0300` then the plain text, as php's does.

Still open, measured and NOT this: php emits compressed output AS IT GOES, while
elephc's shape holds everything until a flush. Reading a `php://memory` or
`php://temp` deflate stream through `rewind()` before removing or closing the
filter therefore answers nothing where php answers a partial block.
…d of keeping it on the stream

php answers `false` when `stream_get_line()` finds neither the delimiter nor the
length cap and the stream is not at EOF, and the bytes it read STAY on the
stream. Measured on `php -n` 8.5.6 over a non-blocking socket pair: writing "abc"
with no newline answers `false`, and once "def\n" arrives the next call answers
"abcdef". elephc consumed the "abc" and answered it — so a reader assembling
records off a non-blocking socket saw each record split wherever the packets
happened to land.

EOF is not that case, and neither is the cap. A blocking file whose last line has
no delimiter still answers that line, because there the loop stopped at a genuine
end of input; `stream_get_line($h, 4, "\n")` still answers four bytes with no
delimiter in sight. Only the would-block exit pushes back.

The bytes go into `STREAM_PENDING_*` on the StreamState, which is php's stream
read buffer narrowed to what makes the difference observable. php shares that
buffer with every read function — a refused `stream_get_line()` followed by
`fread()` sees the same "abc", and so does one followed by `fgets()` — so both
drain it before touching the descriptor. `fgets()` takes the bytes ONE AT A TIME
rather than in bulk, because they can contain a newline: `stream_get_line()`
refuses on ITS delimiter, which need not be `\n`, and a bulk copy would skip the
scan that stops the line.

A stream that never hits the refusal holds nothing, so every reader pays one call
that returns 0 on its first load.
…m_write()

php hands a userspace wrapper at most `chunk_size` bytes per call, so writing 70
bytes to a stream whose chunk size is 42 calls `stream_write()` twice, with 42
then 28. elephc made one call with all 70 — `stream_set_chunk_size()` recorded
the value, reported it back, and nothing else observed it. A wrapper that counts
or frames its writes saw the difference directly.

The loop lives in `__rt_fwrite` rather than in `__rt_user_wrapper_fwrite` because
the chunk size hangs off the StreamState and only the HANDLE reaches it; the
wrapper helper receives the synthetic descriptor, which cannot.

Two details are measured, not assumed. The default here is 8192 — the value
`stream_set_chunk_size()` itself reports as the previous one — and NOT the 4096
`__rt_stream_chunk_size` answers, which is a read-loop fallback: 9000 bytes to an
unconfigured wrapper arrive as 8192 then 808. And a SHORT write is not the end of
the handover. php re-offers from the new position, so a wrapper that accepts four
bytes of every ten still receives the whole payload: 30 bytes at chunk 10 arrive
as 10,10,10,10,10,10,6,2 and `fwrite()` answers 30. The loop stops only when the
wrapper takes nothing, which is what keeps it from spinning.

Measured on `php -n` 8.5.6 across seven chunk/payload/acceptance combinations.
…contents() skipped php's last read

Reading 100 bytes out of a userspace wrapper through `fgets()` made a HUNDRED
`stream_read()` calls. php makes six: it reads a chunk and keeps what the line
does not need, and that buffer survives the call — so the whole file is consumed
in `ceil(100/17)` reads however many `fgets()` calls walk it. The leftovers go
into the same per-stream holding area a refused `stream_get_line()` uses, which
is php's stream read buffer, so `fread()` sees them too.

`stream_get_contents()` had the opposite problem. It asked `stream_eof()` before
each read and skipped the final `stream_read()` when the answer was true. php
does not gate on eof at all: it keeps calling until one call answers an EMPTY
string. Measured on `php -n` 8.5.6, a wrapper serving 100 bytes at a chunk size
of 17 receives SEVEN calls, and one whose `stream_eof()` never answers true still
stops after the read that comes back empty.

Measured and NOT changed here: php asks its source for a WHOLE chunk even when
the cap needs fewer bytes, so `stream_get_contents($h, 30)` at chunk size 17
calls `stream_read(17)` twice rather than 17 then 13. elephc trims the request
instead. Matching php there means the surplus has to become buffered stream state
that `ftell()`, a later read AND a seek all agree about — a first attempt made
`stream_get_contents($h, $len, $offset)` prepend bytes from before the seek, and
two suite tests caught it. The divergence is the request SIZE the hook observes,
never the bytes delivered.
…uld not represent

The `ValueError: No stream arrays were passed` was already right, but it arrived
with nothing to explain it. php prints two warnings first: it names the class
when that class defines no `stream_cast()` — `W::stream_cast is not
implemented!` — and then always reports `Cannot represent a stream of type
user-space as a select()able descriptor`.

The two are not one message with an optional half. A class that DOES define
`stream_cast()` and simply answers `false` gets only the second, which is what
the vtable-slot test distinguishes: the slot is empty in exactly the case php
names the method. Measured on `php -n` 8.5.6 with both shapes.

The first message names the class, so it goes through
`__rt_wrapper_missing_hook_warning`, the composer every other missing-hook
diagnostic already uses.

The x86_64 half is verified by assembling the whole runtime rather than the
program: `cargo run --example dump_x86_runtime` emits it, and `clang -c -target
x86_64-unknown-linux-gnu` accepts it. `--emit-asm` on a program shows only the
CALL — the helper bodies live in a separate cached runtime object, so a program
dump cannot catch a malformed helper.
… not reach its own stream

php publishes `$this->stream` for the DURATION of each `filter()` call and
nowhere else. Measured on `php -n` 8.5.6: the property is UNSET inside
`onCreate()`, a live resource inside `filter()`, and NULL again inside
`onClose()`. elephc left it null throughout, so a filter could not call
`stream_get_meta_data($this->stream)` — which the manual's own filter example
does.

The handle travels from the chain node, which knows which stream it filters, to
the dispatch two calls down, which does not. A global carries it for exactly the
duration of the call: the chain publishes it before dispatching and clears it
after, and the brigade invoker seeds the property from it and takes it away again
when `filter()` returns. Publishing it permanently would be as wrong as never
publishing it, which is why the test asserts all three states.

Where the property lives comes from a new slot 6 in the class's user-filter
vtable, the same mechanism `$params` and `$filtername` already use. It is zero
for a class that declares no such property, and the seeding then does nothing.

Found while probing and NOT fixed here: a read filter that MUTATES its buckets
applies TWICE. php's closing dispatch delivers an empty brigade, elephc's
delivers one empty BUCKET, so `while ($b = stream_bucket_make_writeable($in))`
runs a second time — `$b->data = $b->data . "|"` over "abc" answers "abc||" where
php answers "abc|". A non-mutating filter cannot see it, which is why every
existing test passes.
…ispatch carried a bucket

php gives a filter one final `filter(..., $closing = true)` with an EMPTY
brigade — no buckets at all. elephc built a bucket whatever the input length, so
`while ($b = stream_bucket_make_writeable($in))` ran a second time over an empty
`$b->data`, and the filter applied twice: `$b->data = "<" . $b->data . ">"` over
"abc" answered "<<abc>>" where php answers "<abc>".

A filter that only FORWARDS its buckets cannot observe the difference — an extra
empty bucket concatenates to nothing — which is why the whole existing suite
passed over it. Only a filter that rewrites what it is handed sees it, and that
is exactly what the manual's `strtoupper` example does.

The closing dispatch itself is unchanged: a filter that withholds output until
`$closing` and only then emits still gets its call, which the test pins with a
filter that answers `PSFS_FEED_ME` until the end and then reverses everything it
accumulated.

Measured on `php -n` 8.5.6 in both directions, read and write.
…because fflush() was not a flush point

A `zlib.deflate` stream holds its bytes until zlib's own window fills, so nothing
reached the stream until it CLOSED — a long-lived stream, a socket say,
compressed everything and sent none of it. php makes `fflush()` a flush point for
the filter and pushes a `Z_SYNC_FLUSH` pass, which closes the current block and
emits the `00 00 ff ff` marker. Measured on `php -n` 8.5.6 over 400 bytes to a
file, `filesize()` reads 0 after the write, 12 after `fflush()` and 14 after
`fclose()` — the close adds only the finishing block. elephc read 0, 0, then 8.

The pass belongs to `fflush()` and NOT to the write path. With `Z_NO_FLUSH` per
write a write-then-close stream still answers exactly `gzdeflate()`, which is
php's answer for the same program and what the suite pins; making the write path
sync-flush would have changed every one of those bytes.

The helper lives in the filter ATTACHMENT, not in the runtime, and `fflush()`
calls through a published `_zlib_flush_fn` pointer that stays zero for a program
with no such filter. A first attempt put it in the runtime and broke 472 tests at
once: the runtime is emitted for every program, so every program then had to link
libz and the linker had nothing to resolve `deflate` against. The published
pointer is the pattern the attachment already uses for its fwrite and close
helpers, and it is what keeps libz pay-for-use.
elephc had no closing tag at all. `?>` was a parse error, so a PHP file that
leaves and re-enters code — the shape almost every template uses, and the shape
php-src's own `.phpt` corpus is written in — could not compile at all.

php's rules are three, each measured on `php -n` 8.5.6:

- `?>` terminates the current statement, so the `;` before it is optional.
- ONE newline directly after the tag is swallowed. `<?php echo "A";?>\nX\n<?php
  echo "B";` prints `AX\nB`, not `A\nX\nB`; a `\r\n` counts as that one newline.
- Everything up to the next `<?php` is output verbatim, whatever it contains.

The literal text becomes `echo <string>;` at the TOKEN level, so nothing
downstream learns about inline HTML: the parser, the checker and codegen all see
an ordinary echo of a string constant.

The implicit `;` is emitted only where one belongs. php accepts the empty
statement a doubled `;` makes and this parser has no such production, so `<?php
echo "A"; ?>` would have failed on the spare token — and a `;` after `{`, `}`,
`<?php` or the `:` of `if (...):` is not a statement terminator at all. That last
case is the one that matters: the alternative syntax is exactly what php
templates use to wrap literal text.

`<?=` came out of the same work. It is an OPENING tag, not literal text: leaving
it in the text stream made `Hello <?= $name ?>` print the tag instead of the
value, and a file that OPENED with one was rejected outright. `<?= expr ?>` is
exactly `<?php echo expr; ?>`, comma form included.
…c form

`str_replace(["a","b"], ["1","2"], $s)` did not compile at all. The EIR backend
refused with `str_replace string coercion for PHP type Array(Str)`, because the
shared string-coercion helper has no array case — and rightly so, an array is not
a string. The array form gets its own path instead, so the scalar one is
untouched.

`$replace` may be an array paired term by term, or one string used for every
term; the helper takes both and tells them apart by a null array pointer. Only
`$search` selects the path: an array `$replace` beside a string `$search` is not
php's form.

Two rules are measured on `php -n` 8.5.6 and neither is guessable from the
signature:

- The pairs CASCADE. Each applies to the result of the last, not to the original
  subject, so `str_replace(["a","b"], ["b","c"], "a")` answers `"c"` — the `a`
  became a `b` and the second pair then rewrote it.
- A `$replace` array SHORTER than `$search` pairs the remainder with the empty
  string: `str_replace(["a","b"], ["1"], "abc")` answers `"1c"`, not `"1bc"`.

Thirteen shapes are pinned, including an empty search array, a term longer than
the subject, an empty subject and the scalar form.

Still open on this builtin, measured and NOT included: an ARRAY `$subject`, which
makes php return an array rather than a string and so needs a return type that
depends on an argument's type; and the by-reference `&$count`, which the contract
caps out at three arguments. Both are separable from the form fixed here.

The x86_64 half follows the helper's CALLER, not its doc comment: the two
disagree about which registers carry the search, replacement and subject, and the
code plus its lowering agree with each other.
php ends a LINE comment at a closing tag, not only at a newline:
`<?php echo "A"; // comment ?>TEXT` prints `ATEXT`, because the `?>` closes the
tag even though it sits inside the comment. elephc ran the comment to the
newline, so the tag disappeared and the `<?php` on the next line arrived as
code — a parse error on a file php accepts. Both introducers behave the same
way.

A `/* */` comment does NOT end there: `/* block ?> still comment */ echo "B";`
prints `AB`, so a tag inside it is ordinary comment text and only `*/` closes it.
That asymmetry is why the fix lives in the line-comment skip alone, and why the
block-comment loop is untouched.

The tag is left UNCONSUMED when the comment stops, so the scan loop sees it and
takes the inline-HTML path it already has.

A tag inside a string or a heredoc was never at risk — those are scanned as
literals before the tag probe sees them — but the test pins it, because the
probe's position in the scan loop is what makes that true.

Measured on `php -n` 8.5.6, all three comment kinds.
… array

`str_replace("a", "X", ["abc", "aaa"])` did not compile. php answers
`["Xbc", "XXX"]`: it replaces inside every element and hands back an array.
elephc sent the subject into the shared string coercion, which has no array
case, so the call was refused outright and a list had to be cleaned element by
element in PHP.

What makes this form different from the array $search landed just before it is
that the RESULT SHAPE follows an argument. The contract declares `string`, and
that is right for every call site whose subject is a string — which is nearly
all of them. A blanket `mixed` would have widened all of those. So the builtin
grows a `check` hook instead: it reads the subject's inferred type and answers
`array<string>` for an array, `string` otherwise. That moves the builtin's
result-type source from `declared` to `checked` in the generated docs, which is
the only registry change here.

The new runtime helper `__rt_str_replace_subject_array` walks the subject's
16-byte (pointer, length) slots and dispatches each element to whichever search
form the call used — `__rt_str_replace_search_array` when $search is an array,
`__rt_str_replace` when it is a scalar — so one loop serves both. The result
array is created with `__rt_array_new(0)` BEFORE the loop rather than grown from
a null: `__rt_array_push_str` resolves its argument through
`__rt_array_ensure_unique`, which has no null case. That is also what makes an
empty subject answer an empty ARRAY, as php does, instead of nothing.

A subject with string or sparse keys is refused with a diagnostic that names the
limit, because php PRESERVES the subject's keys and the push-based result keeps
only the dense order a packed array already has. Better a message that says so
than a leaked backend type from the coercion downstream.

Measured against php -n 8.5.6: the scalar and array search forms over an array
subject, an empty subject, a short $replace array, and a re-dump of the subject
afterwards to pin that php replaces into a copy — all identical.

x86_64 verified by assembling the runtime (`dump_x86_runtime` + clang
-target x86_64-unknown-linux-gnu) and a program that uses both forms.

Still open on this builtin: the by-reference `&$count`, which the contract caps
out at three arguments, and the keyed subject named above.
…drops, and warned before it validated

Three divergences in `fgetcsv()` / `str_getcsv()`, all measured against php -n
8.5.6. The first two are SILENT — they change the bytes a correct program reads
back, with no diagnostic anywhere.

1. The escape byte was REMOVED before an enclosure. php never unescapes on read:
   all the escape character does is stop the next byte from closing the field,
   and BOTH bytes land in the value. `"a\"b"` reads back as `a\"b`, four bytes,
   exactly what `fputcsv()` wrote. elephc answered `a"b`. The round trip
   fputcsv → fgetcsv therefore lost a byte per escaped quote, and only there:
   `a\\b` and `a\,b` already came back whole, which is why the suite passed over
   it.

2. The enclosure that CLOSED a field was written back when data followed it.
   php reads `"ab"cd` as `abcd`; elephc answered `ab"cd`. Everything after a
   closing quote is ordinary data, quotes included — `"ab"c"d"` is `abc"d"` —
   so the byte elephc restored is one php has already consumed.

3. Whitespace in FRONT of an opening enclosure was kept as data. php looks ahead
   from the start of a field and, if the first byte that is neither the separator
   nor whitespace is the enclosure, starts the field there: ` "a",b` reads as
   `a`, while ` a,b` — no enclosure ahead — keeps the space and reads as ` a`.
   elephc kept the space in both, so a CSV pretty-printed with a space after each
   comma read every field but the first with a leading space and its quotes
   intact.

   The lookahead is bounded by the BUFFER, not by a newline test, and that is
   what makes one implementation serve both callers: `fgetcsv()` holds one line
   and cannot reach a quote on the next, while `str_getcsv()` holds the whole
   subject and can — php answers `[" "]` then `["a"]` for the stream and `["a"]`
   for the string, and both fall out of the same bound.

4. And the `$escape` deprecation came BEFORE the control characters were
   validated. php checks the separator, enclosure and escape for being a single
   character first, so a call that throws `ValueError` never prints the notice.
   `fgetcsv($h, 0, ";;")` is one line on php and was two here. Moved after
   `emit_csv_control_bytes()` in all three lowerings, with the ordering written
   into the helper's doc comment so the next caller does not reintroduce it.

Seven differential fixtures over the reader's whole surface — enclosures,
escapes, embedded newlines, blank records, unterminated fields, custom control
characters, the SplFileObject::READ_CSV path — are now byte-identical to php.
The four new tests pin the cases that already worked alongside the ones that did
not, so a fix that merely swaps which byte is lost fails them.

x86_64 verified by assembling the runtime (`dump_x86_runtime` + clang -target
x86_64-unknown-linux-gnu).

Re-measured from the same sweep and found ALREADY CLOSED, no work needed:
`string.strip_tags` removal, `php://output` through the output buffer,
`mkdir($p, 0755, true)`, the CSV single-character ValueError, `SplFileObject`'s
READ_CSV reader, and a user filter's `&$consumed`.
…line-shape filters

`$name = "zlib.deflate"; stream_filter_append($h, $name);` attached nothing and
answered `false`, while the identical call with the literal compresses. php makes
no such distinction, and a filter name held in a variable is ordinary PHP — a
config value, an entry in a list the program loops over.

The five are missing from `BUILTIN_FILTER_NAMES` on purpose, and adding them
there would have been worse than the bug: that table lists what a CHAIN NODE can
apply — a byte transform the resolved id selects — while `zlib.*`, `bzip2.*` and
`convert.iconv.*` each install a per-fd handle plus a program-local helper thunk
(`_zlib_fwrite_fn` and friends) that only a compile-time attach sequence emits.
Naming them there mints a node whose id the chain hands to a transform that does
not exist, so the attach reports success and filters nothing.

So the attach SEQUENCES are emitted at the call site instead, and a run-time name
comparison picks between them. `convert.iconv.*` carries its two charsets inside
the name, which a literal splits during lowering; the dynamic arm emits the same
sequence against two program-local buffers and `__rt_iconv_spec_split` fills them
just before it runs. The shapes are untouched — they already take the ADDRESS of
a symbol, and the bytes there are now written rather than assembled.

Measured against php -n 8.5.6, a dynamic name is now byte-identical to a literal
one for all five. Both still differ from php on compression over a php://memory
stream, which is a separate pre-existing gap and identical on the two paths.

The iconv REFUSALS were wrong on both paths, and are fixed for both:

- `convert.iconv.` and `convert.iconv.UTF-8` have no separator, so php has no
  filter for them and answers `false`. elephc attached an inert node and reported
  success.
- `convert.iconv.nope/alsonope` names a conversion `iconv_open()` cannot open.
  php creates the filter at ATTACH time and so finds out there, answering `false`;
  elephc attached and only found out inside the transform, where the shape leaves
  the bytes unconverted — a typo'd charset looked like a working filter.
- An EMPTY half is none of those: `convert.iconv.UTF-8/` and `convert.iconv./UTF-8`
  both attach, iconv reading the empty string as the current locale's charset. The
  lowering refused those as malformed, which is the opposite of php.

Each refusal now carries php's own wording. php picks its verb by WHY the attach
failed — `Unable to locate filter "nosuchfilter"` when no factory claims the name,
`Unable to create or locate filter "convert.iconv."` when one claims it and then
refuses — and every `convert.iconv.` name reaches the second, the prefix being
what selects the factory. `__rt_filter_create_warning` composes that line.

Linking follows: a non-literal name reaches all three libraries whichever branch
runs, so `stream_filter_requirements` now asks for `z`, `bz2` and `iconv` when the
name is not a literal. Without it the deflate and iconv symbols reached the linker
unresolved as soon as the sequences were emitted.

x86_64 verified by assembling the runtime (`dump_x86_runtime` + clang -target
x86_64-unknown-linux-gnu) and a program that attaches all five by variable.
```php
function f(&$a): void { $a = 5; }
$x = null;
f($x);
var_dump($x);        // php: int(5)   elephc: NULL
if ($x === null) {}  // php: false    elephc: true — the branch went the wrong way
```

SILENT. No warning, no compile error. Confirmed for every written type — int,
string, array, bool, float. This is php's out-parameter idiom, the way PHP code
declares an out parameter, and it answered the wrong value in every program that
used it.

The write always happened: the callee stores through the pointer, and
`count($o)` inside a `&...$vars` callee returns the right number, so the
arguments were passed correctly. What never moved was the caller's TYPE. elephc
types a parameter from its call site, so `&$a` was typed `null`, the body's
`$a = 5` widened it flow-sensitively INSIDE the callee only, and the caller kept
reading its slot as null-typed — which constant-folds every read to `NULL`
without ever loading the slot. The call site's EIR is byte-identical to the case
that works (`$x = 1; f($x)`), which is why `--emit-ir` shows nothing and the
8000-test suite passed over it. The probe that separates them is the `php=<type>`
on the `load_local` AFTER the call.

The repair follows the path already paved for by-reference ARRAYS.
`by_ref_array_params_widened_by_body` compares a by-reference parameter's entry
type against what the body leaves in `local_env` and re-resolves the signature in
a fixed point; `apply_by_ref_array_arg_types` then re-types the caller's variable.
Both stopped at arrays. They now also fire when the ENTRY type is `null` and the
body writes something else, and the lowering — which keeps its own local-type map
— converts and re-types the caller's local to match, mirroring the `ArrayToMixed`
the array path emits for the same reason.

The widened type is `mixed`, not the narrower `<written>|null` it looks like it
should be. Both are boxed, but a nullable SCALAR union has its own inline
representation — a payload word plus a tag word, sixteen bytes — while the
caller's slot was laid out for the eight-byte null it was holding, so the callee's
write ran past the end of it and the program SEGFAULTED. That is measured, not
reasoned about: the union version crashed `$x = null; f($x)` for an int write
while the string and array writes passed, because only the int union takes the
inline representation.

Only a `null` entry widens. A parameter the caller passed a real value to keeps
elephc's monomorphized contract — `sort($a)` and every other by-reference callee
compiled for raw slots is untouched, and a body that writes an incompatible type
over a typed caller is still the reassignment error it has always been.

Measured against php -n 8.5.6: the five written types, a callee that writes on
only one path (stays null), a callee that only reads (stays null), two by-ref
parameters in one call, and the comparisons that made this a correctness bug
rather than a display one — `$c === null` and `$c + 1`.

Still open, and separate: the by-reference VARIADIC `&...$vars` on a USER
function, which is a tail of references rather than a parameter and takes none of
this path. `Op::InvokerRefArg` collects the addresses into an array, and a write
to an element replaces the address instead of following it — so `count($vars)`
answers correctly inside the callee while every write vanishes.
…ut form

`fscanf($h, '%s %d', $name, $age)` is the manual's own idiom and did not compile:
the call was refused outright, so the only way to scan was to read the returned
array. php assigns each field through the reference and answers the conversion
COUNT instead.

Three things had to move, and each one alone is invisible.

1. `variadic_writes` on the contract. `variadic: Some("vars")` was a bare NAME
   with no way to say the tail is WRITTEN, so the checker read those arguments
   and rejected `$name`/`$age` as undefined variables — php materializes them as
   null before the call. The field is the variadic counterpart of
   `ParamSpec::writes`, which already binds `$errno`/`$errstr` for
   `stream_socket_client()`, and `out_params.rs` consults it past the fixed
   parameters. Only `sscanf` and `fscanf` set it.

2. One prelude wrapper PER ARITY, `__elephc_scanf_vars_1` through `_8`. The
   obvious shape is a by-reference variadic and it does not work here: `&...$vars`
   collects ADDRESSES into an array through `Op::InvokerRefArg`, and a write to
   `$vars[$i]` replaces the address instead of following it — `count($vars)` in
   the callee returns the right number while every write vanishes. Past the last
   arity the builtin refuses with a message that names the limit.

3. The wrappers declare `mixed &$vN`, not a bare `&$vN`. An untyped by-reference
   parameter takes its type from the call site, and a prelude function is resolved
   before any call site is seen, so it fell back to the `int` placeholder: the
   caller handed over a Mixed cell pointer and the callee wrote an int through it.
   That SEGFAULTED, and only on a program that read the variable back — the
   arity-error tests passed throughout.

The caller's side needed the same auto-vivification php performs. A write-only
variadic argument is now stored as a freshly boxed `null` before the call, which
initializes the slot AND re-types it: an undeclared one held whatever the frame
held and the callee's store into a `mixed` reference released that as if it were
a value, while a declared one holding `null` kept `php=null` in the lowering's
own local-type map and every read of it constant-folded to `NULL`.

The COUNT is the subtle part. It is not `count($values)`: php counts every
conversion that consumed input, the SUPPRESSED ones included, so
`sscanf("1 2 3", "%d %*d %d", $a, $b)` answers 3 while filling two variables. The
engine already tracks exactly that number, so `__elephc_scanf_ref()` now returns
it beside the values and `__elephc_scanf()` is a wrapper over the same scan —
there is still ONE engine, and the array form is unchanged.

Every rule measured on php -n 8.5.6 and pinned:
- input exhausted before any conversion succeeded answers `-1`, where the array
  form answers `null`;
- `fscanf()` on a stream already at end of file answers `false`, and does so
  BEFORE the variable-count check, so nine variables raise nothing there;
- more variables than conversions raises `Variable is not assigned by any
  conversion specifiers`, fewer raises `Different numbers of variable names and
  field specifiers` — the wordings are easy to swap and the test pins both;
- a suppressed conversion is not a variable, so `"%d %*d %d"` wants two.

The two tests that pinned the old refusal now pin the new bound instead.
`eval()`'s interpreter still refuses the form; that surface has its own reference
model and is untouched here.
…l $write/$except

`stream_select($r, $w, $e, 0)` with null write and except sets — the shape every
read loop in PHP is written in — answered a constant 15. php answers the number
of ready streams.

The by-reference out-parameter conversion added in 3c8301b was placed in the
SHARED argument lowering, so it fired for builtins too. It converts a caller's
null into a boxed Mixed cell, which is right for a user function whose body
writes through the reference and wrong for a builtin: `stream_select()` reads a
null set as an EMPTY set, so the runtime took the cell's header for an array
length, handed `poll()` fourteen uninitialized entries, and counted every one of
them. The arrays were not compacted to the ready subset either.

The conversion now runs from the user-function call site only. A builtin's
by-reference `mixed` parameter carries its own convention for what the caller
hands over, and the shared lowering has no business overriding it — the builtins
that DO auto-vivify say so through the contract, with `ParamSpec::writes` or
`variadic_writes`, and reach their own path.

The regression test that was missing is the reason this shipped: passing `[]`
instead of `null` was correct throughout, so every existing `stream_select` test
passed. It now checks both spellings side by side, and the compaction of each
array to its ready subset, which is the other half of the contract php specifies.
…here php refuses it

A MEMORY stream is bytes in the heap: there is no operating-system descriptor to
poll. php says so and drops the entry — `Warning: stream_select(): Cannot
represent a stream of type MEMORY as a select()able descriptor` — and raises
`ValueError: No stream arrays were passed` when that leaves nothing selectable.

elephc polled the stream's backing descriptor instead and reported it ready. A
select loop that blocks forever on php therefore spun here, and one written to
wait on a memory stream got a `true` it had no business getting.

Only MEMORY is refused, which is the part worth measuring rather than assuming:
`php://temp` selects fine — it is backed by a real file — and so do `data:`, a
plain file, and the standard streams. All four measured on php -n 8.5.6.

The refusal reuses the path a user wrapper without `stream_cast()` already takes.
`__rt_stream_select_memory_guard` answers -1 for a memory handle, which is the
same "unusable descriptor" a CLOSED stream produces, so the pollfd store, the
castable tally that decides between polling and throwing, and the compaction that
keeps only ready slots all treat it exactly as they already treat those. Nothing
in the select loop needed a new branch.

The type is recognised through `__rt_stream_type_name`, the same helper
`stream_get_meta_data()` uses, so the two can never disagree about what a stream
IS — and the comparison is on the returned POINTER, since every recorded name is
one interned literal.

The guard runs on BOTH passes, because php's does. php walks the arrays twice —
once to build the descriptor sets and once to translate the result back — and
names the stream each time, so a memory stream sitting beside a real one warns
TWICE while a memory-only call warns once, its ValueError landing before the
second pass. Three warnings across the test's five calls, matching php exactly.

x86_64 verified by assembling the runtime (`dump_x86_runtime` + clang -target
x86_64-unknown-linux-gnu).
…e it boxed itself

`function f(&...$out) { $out[$i] = $a + $b; }` left the caller's variables
untouched, silently. `$out[0] = 99` wrote through correctly, so the feature
looked present and only some writes vanished.

The write-through machinery was never missing. `$out[$i] = …` on a by-reference
variadic already resolves the array slot's invoker ref-cell marker and stores
into the CALLER's storage — `emit_mixed_array_set_ref_marker_writeback`. What
gated it was one line:

    let fresh_boxed_value = !matches!(value_ty, Mixed | Union(_));
    if fresh_boxed_value { emit_..._ref_marker_writeback(ctx); return Ok(()); }
    abi::emit_call_label(ctx.emitter, "__rt_array_set_mixed");

so the marker was consulted only when the lowering had boxed a raw scalar
itself. A replacement that arrived ALREADY boxed went to the runtime setter,
which replaces the array element and never follows the marker. That covers
everything `ichecked_add` and concatenation produce — which is every arithmetic
or string write, i.e. every interesting one.

Both shapes now run the marker path. The disposal is what differs and is the
part that bites: a wrapper this lowering boxed is owned outright and freed,
while one that arrived already boxed was only retained on the way in and must be
RELEASED — freeing it would drop a reference the value's other holders still
count on.

The isolation took a correction worth recording. `$i = 0; $out[$i] = 55;` also
wrote through, which looked like "a variable index works" and pointed at the
loop; it works because the checker CONSTANT-FOLDS that index, so the probe was
testing a literal. The real split is the shape of the VALUE, not of the index —
a genuinely runtime index from a parameter always worked.

Measured against php -n 8.5.6: a literal index, a runtime index from a
parameter, a `for` loop writing `70 + $i`, and a loop writing a concatenated
string. That last one covers the two-word case, where a caller cell holding a
string is a pointer AND a length and writing one word leaves the length behind.

Still open, and separate: `foreach ($out as $i => $_) { $out[$i] = …; }` lowers
to `__rt_array_set_mixed_key`, a different helper with no marker detection and
its own ownership contract — it takes an OWNED array reference because its
promote paths abandon the source. It also needs the key proved to be an integer
before a slot can be indexed at all.
… reached no caller

`function f(&...$out) { foreach ($out as $i => $_) { $out[$i] = …; } }` is the
natural way to fill a variadic out-parameter, and it reached the caller for none
of its variables. Silently, as the other shapes did.

Two remaining roads, both now closed.

A `foreach` key is a boxed Mixed in EIR, so the element write lowers to
`__rt_array_set_mixed_key` rather than the direct setter whose lowering carries
the marker check inline. That helper's integer-key path DELEGATES to
`__rt_array_set_mixed`, which had no marker detection of its own and simply
replaced the array element. The detection now lives in that runtime helper, so
every road into it is covered — the delegating `foreach` path included — and
`__rt_array_set_mixed_key` needs no change: its distinct ownership contract, an
OWNED array reference its promote paths release, stays untouched.

The check runs BEFORE `ensure_unique`, because a marker write mutates the
CALLER's cell rather than this array: there is nothing to split, and the array
pointer has to come back unchanged.

The second road is the caller's storage. A by-reference variadic argument still
holding `null` has no Mixed cell for the write to land in, so the auto-vivify
that already covered fixed by-reference parameters now covers the variadic tail
too. Without it the write went through the marker into a slot the caller read as
null-typed, and every variable stayed NULL.

Measured against php -n 8.5.6: a `foreach` fill over three `null` callers, the
same over typed ones, and a `foreach` writing a concatenated string — the
two-word case, where a caller cell holding a string is a pointer AND a length.

x86_64 verified by assembling the runtime (`dump_x86_runtime` + clang -target
x86_64-unknown-linux-gnu).

With this the by-reference variadic matches php across every shape measured:
literal index, runtime index, `for` loop, `foreach` key, null and typed callers,
integers and strings.
…h the wrapper

`$name = "compress.zlib://out.gz"; fopen($name, "w");` answered `false` where the
identical call with the literal compresses — in both directions. The wrapper was
reachable only from a compile-time literal, because that is what the split into
"wrapper" and "underlying path" needed, and a URL built with
`sys_get_temp_dir()` or read from a config file is ordinary PHP.

The opener is now parameterised over where the underlying path comes from: baked
as a data string for a literal, derived by pointer arithmetic over the staged
string registers for a URL only known at run time. The attach sequences are
shared unchanged, so a computed URL produces the same bytes as a literal one —
which the test checks by reading each spelling's output back through the OTHER.

`compress.bzip2://` is compared for the same reason. Its READ direction now
works from a run-time URL too; its WRITE direction still refuses, exactly as it
refuses from a literal — the code has no bzip2 deflate write shape yet and says
so, and that is a missing feature rather than a dispatch gap.

One trap is worth naming, because it segfaulted before it was found. In write
mode `emit_publish_zlib_wrapper_level()` runs BEFORE the open, and the option
walk clobbers the very registers that carry the URL. The literal path never
notices: it materializes its path from a data symbol AFTER that walk. The staged
path read whatever the walk left behind, so the URL is now spilled across it.

Measured against php -n 8.5.6: read and write, literal and computed, a new file
and a pre-existing one, and a cross round trip in both directions — plus a check
that the bytes on disk are NOT the plain payload, since a wrapper that merely
passed bytes through would round-trip just as happily.

x86_64 verified by assembling a program that opens both spellings.
php's CLI writes `Warning:`, `Notice:` and `Deprecated:` to STDOUT, through the
output buffer. MEASURED on `php -n` 8.5.6:

  php -n w.php 2>&1 1>/dev/null      -> EMPTY
  ob_start(fn($s) => "[[$s]]"); ...  -> the warning comes back wrapped in [[ ]]

So a program that captures its own output sees its warnings, and one that
redirects stderr sees nothing. elephc wrote them to fd 2 by raw `write(2)`,
which is the opposite on both counts: `ob_get_clean()` returned a string php
fills, and `2>/dev/null` hid what php keeps.

`__rt_diag_warning` now buffers and routes through `__rt_stdout_write`. The
buffering is what makes the location possible: one diagnostic is composed from
SEVERAL calls (head, name, tail), so appending php's ` in FILE on line N` per
call would stamp it three times. Pieces accumulate in `_rt_diag_buf` and go out
together when the piece carrying the newline arrives.

The line is published per instruction by the lowering, gated on
`Effects::MAY_WARN` so a program that cannot warn emits none of the stores. It
is rendered at COMPILE time because both halves are constants there — and
because the obvious run-time formatter, `__rt_itoa`, writes through the shared
concat buffer, so a warning raised mid-concatenation would corrupt the string
being built.

Three warnings turned out not to publish a location at all, which the move made
visible because an unlocated line is not php-shaped:

  - `$http_response_header`'s deprecation is the one diagnostic NOT raised by an
    instruction: php raises it while COMPILING the file, and elephc emits it
    from the main prologue, where there is no span to read. It now names the
    line of the first mention, which is what php names — MEASURED: an
    unreachable `if (false) { echo $http_response_header; }` on line 3 still
    prints `on line 3`.
  - `unexpected NAN value was coerced to string` is raised by `__rt_ftoa`, which
    cannot know its line. `FToStr` and `MixedCastString` now declare `MAY_WARN`,
    and `EchoValue` refines it per site: whether an echo can warn depends on
    WHAT is echoed — a float can be NaN, a string literal cannot — so declaring
    it unconditionally would make every `echo "..."` pay for the location stores.
  - eval's warnings reach the same funnel through `__elephc_eval_warning`, but
    its twenty-five call sites pass a BARE message: no `Warning: ` and no line
    terminator. Unterminated, they were buffered and never written at all. Both
    are added at the single sink they pass through, idempotently — a few sites
    already pass a fully formed diagnostic, and blindly wrapping those split the
    location onto a line of its own.

Tests: `ProgramOutput` gained `diagnostics` (the wording) and
`located_diagnostics` (php's full line, path folded to its basename). WHICH
STREAM a line belongs to is decided by its kind prefix; whether it carries
php's location is a separate property, asserted through `located_diagnostics`
by the tests that care. Conflating the two made every location gap surface as
"the program printed something unexpected" rather than as a missing suffix.

Assertions migrate from `out.stderr` to `out.diagnostics`. Kept on stderr
deliberately: Rust panics, the heap debugger's leak summary, and the
fatal/uncaught family — only the warning helper moved, and the
uncaught-exception report is a separate helper.

Two classes of assertion were repaired rather than renamed:

  - negations (`!out.stderr.contains("Undefined array key")`) had become
    unfalsifiable against an always-empty stderr, so they passed while testing
    nothing;
  - `assert_eq!(out.stderr, "")` said "this program is silent" and had become
    automatically true. Each is now PAIRED with the diagnostics half rather than
    replaced, so no coverage is given up.

Also here, because its test and its doc line share files with the above:
`compress.bzip2://` can now WRITE. `fopen("compress.bzip2://out.bz2", "w")`
opened the underlying file and then wrote PLAIN bytes through it — the write
succeeded, the byte count was right, and the file was one no bzip2 reader
accepts. The wrapper carried an explicit guard that downgraded a write open to
the read direction, which kept the lie consistent instead of surfacing it. The
compress side already existed as the `bzip2.compress` FILTER; it is now attached
on the write path with php's wrapper defaults, block size 9 and work factor 0.
…ches grew

101 upstream commits against 304 of this branch, 1136 files changed since the
merge base. A REBASE was not the right tool: this branch's history already
carries eight merges of origin/main, so replaying it would re-apply upstream
work that is already here — the shape that cost another branch 199 files.

606 conflicts, of which 531 were generated builtin docs (resolved by rerunning
their generator) and ~70 were code. Every one of the code conflicts was decided
by checking what the merged tree actually contains, never by preferring a side:

  - `array_unique`: ours called `__rt_array_unique_to_hash`, which the merged
    runtime no longer emits. Upstream's names are the ones that exist, so its
    lowering is what links.
  - `hash_sort`: upstream's submodules replace a quadratic insertion sort with
    an O(n log n) merge sort — and contain NEITHER `natsort` NOR `natcasesort`.
    Taking them would have deleted two builtins to buy a complexity class, so
    the insertion sort stays until the natural comparators are ported onto it.
  - the user-wrapper vtable: both sides write ONE trailing quad after the method
    pointers, ours the `$context` property offset and upstream a boxed-result
    mask. `fopen.rs`, `user_wrapper.rs` and `user_wrapper_path_op.rs` — all
    merged without conflict — read that quad as the mask, and nothing reads it
    as an offset, so the mask is what it holds.
  - `fopen`, `count`, `hash` sorting and the by-reference argument checks each
    needed BOTH sides: two different fixes had landed on the same lines, and
    either one alone still compiles while silently dropping the other.

Two defects surfaced that no conflict could show, because both sides merged
cleanly into them:

  - `get_object_vars` arrived from upstream without the `writes` /
    `variadic_writes` fields this branch added to the builtin contract.
  - the eval bridge CALLS `__rt_key_compare_regular`, whose emitter upstream
    registers from `hash_sort.rs` — the file whose other side was kept. The
    result was an undefined symbol at LINK time, invisible to `cargo check`,
    found by following a dead-code warning rather than silencing it.

Counters are measured on the merged catalogue, not taken from either side:
14 non-registry contracts (ours said 15, main 13) and 544 AOT registry entries
(ours 543, main 531) — one MORE than either, because main also promotes
`get_object_vars` into the registry.

Left deliberately unreconciled, and marked as such rather than deleted: both
branches grew a full stat dispatcher, and `stat_ops.rs` routes to this one.
Removing upstream's would revert its work exactly as silently as taking it
would have reverted ours.
@Guikingone Guikingone changed the title feat: PHP streams compliance audit feat(core): finalize PHP streams implementation Aug 21, 2026
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:xl Very large pull request that needs deliberate review planning. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant