feat(core): finalize PHP streams implementation - #638
Draft
Guikingone wants to merge 305 commits into
Draft
Conversation
Guikingone
force-pushed
the
feat/streams-csv-signatures
branch
from
July 29, 2026 08:21
6a229f2 to
f133b17
Compare
Guikingone
force-pushed
the
feat/streams-csv-signatures
branch
from
July 29, 2026 08:44
f133b17 to
70bc7a4
Compare
Guikingone
force-pushed
the
feat/streams-csv-signatures
branch
2 times, most recently
from
July 29, 2026 11:43
e6b27a2 to
239ea4a
Compare
|
Too many files changed for review (1168 files, 100 file limit). |
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`.
…ist-shape divergence
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
fopen(..., $context)restorationstream_socket_client/serverfull 7-arg/6-arg signatures with&$errno/&$errstrby-ref validationstream_select→poll(2), removing the 64-fd ceiling (cap 256)stream_get_meta_datarealwrapper_type/urivia per-handle tables (file, php, data, http, ftp, compress.zlib/bzip2)file_get_contents/file_put_contents/readfilefull signatures (offset, length, flags, context)fgetcsv/fputcsvfull PHP 8.4 signatures (enclosure, escape, eol) + 5-state CSV parserstream_filter_prependwith 2-slot filter chain (shift + insert at slot 0)PSFS_FEED_ME/PSFS_ERR_FATALpropagation from user stream filtersstream_get_filters()getaddrinforeplaces deprecatedgethostbynamein DNS resolution%cand%xformat specifiers insscanf/fscanfstream_set_chunk_sizeeffective instream_get_contentsset_file_buffer,socket_set_block, …)SEEK_*,STREAM_FROM_*,STREAM_OPTION_CHUNK_SIZE,STREAM_META_MODIFIED,STREAM_PF_INET6)stream_is_localbased on scheme/wrapper_id instead of alwaystrue$http_response_header__rt_get_http_response_headersAudit fixes from a 3-model jury:
fgetcsvescape default back to"\\"(8.4, not 9.0),stream_socket_clientflags default1(STREAM_CLIENT_CONNECT), andSTREAM_CLIENT_*values corrected against php-src.
Wave 2 — resource identity, lifetime, and eval parity
slots with a generation, not raw descriptors, so a stale handle cannot resolve to a
recycled descriptor.
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 streamopened before it — and user resources start at 5. Ids are never reused.
Unknownat every display site (var_dump(),get_resource_type()), forfclose/pclose/closediralike, while keeping their id.php://filterattaches through the same chain, and filters close with their stream.previously every compiled program leaked the slot table whether or not it used streams.
SIGPIPEis ignored (a closed peer used to kill the process),the TLS attach path completes its handshake and honours
ssl.verify_peer, andfreadno longer truncates a stream at 64 KiB.
eval()parity: the eight stream aliases carry eval signature metadata, socketby-ref params are declared in both required forms, a host-closed handle reports
Unknowninsideeval(), and eval-created streams draw from the same id counter ascompiled code.
fopen('php://stdout')now duplicates the descriptor, as php-src does. It used tohand back descriptor 1 itself, so
fclose()on that handle closed the program's realstandard output and every later
echowas silently discarded while the process stillexited 0.
foreachover an array of resources (refused asstream argument PHP type Int),implode(',', [false, false])(refused asarray_push for PHP type False), thedata:wrapper reported asdatainstead ofPHP'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 _signalon the whole AArch64 arm. The leading underscore is a PLATFORMproperty, not an architecture one, so every Linux AArch64 program failed to link with
undefined reference to '_signal'.Emitter::bl_cresolves it per platform.__rt_resource_registry_teardownpassed the slot array inrdiwhile__rt_heap_freereads its operand fromrax. Every CLI program on linux-x86_64segfaulted at exit, AFTER printing correct output, which took the whole platform's
Codegen/Eval Codegenmatrices down. Reproduced in the repo's own x86_64 containeron
<?php echo 1;(run=139), fixed, and re-verified there (run=0). One shard wentfrom ~672 failures to 4.
Also fixed after CI exposed them:
stream_socket_client/serveraccepted one argumentmore than PHP (a
peernameparameter PHP does not have), seven stale arity expectationsin
error_tests, and a?intinside an array literal that crashed__rt_array_push_refcountedbecause a nullable union never reached the tagged-scalar arms.Green locally:
--lib2422/2422 ·-p elephc-magician1134/1134 ·error_tests362/362 ·runtime_gc316/322 ·runtime_gc::heap16/16 ·ir_backend_smoke_test+ array suites286/286 ·
eval_resource_id_tests14/14 ·resource_id_and_hash_context_tests25/25 ·builtin_parity_tests32/32 ·codegen::io::streams362/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-sizeused to dieat startup with
Fatal error: heap memory exhausted, and every program allocated 528 bytesit never needed (
allocs=0now under--heap-debug) — and a?intinside an array literalno longer appends its tagged immediate through the refcounted path.
Known red, characterized
_tls_sessionsoff the raw-fd side table; the implementation is preserved onwip/tls-session-in-streamstateand still needs theftps://call sites, SNI stringownership, and PHP's refusal to re-enable crypto.
FilterStatealready carries aphp_user_filterslot and__rt_filter_createalready accepts one, butlower_user_stream_filter_attachstill routes a genuinely user-registered filter to thelegacy 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() === trueafter its stream closes, andstream_filter_removedetaches it unconditionally instead of honouring
PSFS_ERR_FATALfrom the closingflush. Finishing that migration is the remaining half of the filter work.
fopen(..., $context)through an untyped parameter. An untyped parameter is seededPhpType::Intby the checker, sofunction f($c) { return $c; }reportsintfor aresource. Pre-existing and documented in
fopen_core.rs; acceptingIntin the emitterswas tried before and reverted (it trades a clear diagnostic for a runtime throw), so the
fix belongs in the checker.
stream_selectwrapper-stream cast readiness, adynamic
ftp://URL throughfile_get_contents,fpassthrucapture under outputbuffering, and
PSFS_PASS_ONcontrol from a user filter. All four pass on macOS, so theyare only observable through this PR's CI.
Docs
CHANGELOG.mdgains the user-facing entries for both waves.docs/php/streams.mddocuments the id model, the duplicated
php://descriptor, and the remaining reported-namedifferences (
stream_typeforphp://memory/temp/outputanddata:, the emptyurifor
tmpfile(), literal-onlyphp://resolution).docs/php/eval.mddocuments the sharednumbering space. The 962 generated builtin pages were regenerated from the registries.
Merge
origin/mainis merged in (15 commits:parse_url#667, by-ref foreach #648). Everyconflict was a generated builtin page — 152 of them, no source conflict — resolved by
regeneration rather than by picking a side.