Skip to content

feat: add PHP ext/curl on pinned libcurl - #730

Draft
nahime0 wants to merge 123 commits into
mainfrom
feat/curl
Draft

feat: add PHP ext/curl on pinned libcurl#730
nahime0 wants to merge 123 commits into
mainfrom
feat/curl

Conversation

@nahime0

@nahime0 nahime0 commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

First-class PHP ext/curl on every supported target. Programs get the full PHP 8.2–8.5 function, class, and constant surface on a checksum-pinned libcurl 8.21.0. There is no system, Homebrew, or distro -lcurl.

elephc native add curl
elephc app.php
# or: elephc app.php --with-curl

extension_loaded('curl') is true only when the bridge is linked. A curl-free program never declares the classes, never links elephc_curl, and never needs the native package.

What shipped

  • 35 functions, 6 classes, 689 constants. 260 of 271 CURLOPT_* options work; the other 11 (blobs and three leftover callbacks) return false plus PHP's warning. Never an inert true.
  • Easy, multi, and share, including PHP 8.5 curl_multi_get_handles, curl_share_init_persistent, and CurlSharePersistentHandle (gated by --php-version).
  • CURLFile / CURLStringFile / curl_file_create, with array CURLOPT_POSTFIELDS as real multipart/form-data.
  • Six callbacks (WRITE / HEADER / READ / PROGRESS / XFERINFO / DEBUG) through the existing runtime callable invoker. Throws abort the transfer, stay catchable, and never unwind through libcurl.
  • Stream options CURLOPT_FILE / INFILE / WRITEHEADER / STDERR on the AOT path.
  • The same surface inside eval(), except those four stream options (honest false + warning).
  • Pinned native stack: libcurl 8.21.0, OpenSSL 3.5.7 (TLS for libcurl only — openssl_* / hash() stay on RustCrypto), zlib 1.3.2, nghttp2 1.70.0, libssh2 1.11.1.
  • 25 protocols, including HTTP/2, SCP/SFTP, and the usual mail/messaging set. Not LDAP, not HTTP/3.
  • Runtime CA discovery ($CURL_CA_BUNDLE, then the baked path if it still exists, then seven root-owned distro locations), applied to both CURLOPT_CAINFO and CURLOPT_PROXY_CAINFO. Explicit user options replace or compose as in a stock libcurl.

User-facing contract and every documented PHP divergence: docs/php/curl.md.

Out of this PR

  • Bundled CA material for FROM scratch / distroless images.
  • HTTP/3 (needs ngtcp2 + nghttp3 on a later curl pin).
  • LDAP / LDAPS (catalog cannot yet declare system-library needs).
  • CURLOPT_FILE / INFILE / WRITEHEADER / STDERR inside eval().

This is not the v0.28 Zend curl.so consumer PoC. extern "curl" stays user FFI.

Tests / CI

  • Dedicated curl-codegen-tests-* jobs (4 shards × 3 targets) run the ~194 codegen fixtures after elephc native install --locked. They fail if the managed-native skip gate fires.
  • managed-native-smoke builds the curl stack from source and compiles examples/curl-get.
  • Catalog / Magician / prelude signature audits run in curl-feature-contract.

Review notes

PHP 8 objects (CurlHandle, …) come from an injected prelude over internal __elephc_curl_* builtins, same pattern as HashContext. The bridge owns libcurl handles by never-reused i64 ids. A few signatures throw instead of returning T|false because the checker cannot narrow those unions — listed in the docs, not silent.

@github-actions github-actions Bot added area:builtins Touches PHP builtin declarations or emitters. area:codegen Touches target-aware assembly or backend lowering. area:magician Touches eval, include execution, or elephc-magician. 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. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. and removed area:codegen Touches target-aware assembly or backend lowering. labels Aug 18, 2026
nahime0 added 19 commits August 21, 2026 15:59
The EasyEntry Send comment overclaimed that the table mutex prevents two
threads from ever driving the same handle. It doesn't: perform/free both
drop the lock before calling into libcurl (to avoid a write-callback
deadlock), so a concurrent free(id) can free the CURL* out from under an
in-flight perform(id) on the same id. Document the actual guarantee (table
integrity only) and the caller contract that makes today's usage sound
(no concurrent calls on the same id), at both the Send impl and the
perform/free ABI entry points, so a future concurrent caller (e.g. Task 9)
doesn't rely on the old, inaccurate wording.
… param

The example's headline request used https://example.com/, which this landing's
curl/openssl recipe cannot reliably verify (no CA bundle baked in) - swap it for
plain HTTP and note HTTPS arrives with Task 8's TLS wave. Verified end to end
against the real network via a scratch native-project link (real HTTP 200,
real connection-refused failure).

curl_close()'s parameter was renamed $handle -> $_handle to silence an unused-
variable warning, but the name is PHP-visible (curl_close(handle: $ch) is legal
named-argument syntax) and the rename silently broke it. Restored $handle
verbatim and documented the accepted warning instead of inventing a new
suppression mechanism.
nahime0 added 29 commits August 21, 2026 15:59
`--enforce-target-architecture` matched only single-expression match arms, so
the two arms rustfmt wraps in braces (CurlSetoptUnsupportedWarning and
CurlMultiSetoptUnsupportedWarning, whose symbol names do not fit on one line)
were invisible to the name map. The audit reported four errors and exited 1 —
which the builtins-docs-sync CI job runs as a step. Pre-existing on this branch:
the errors go to stderr, so a merged `| tail` hides them behind the summary.

Applied the same tolerance to the UnaryStringRuntime map, which has the
identical latent hole.
The generated builtin documentation is now produced in ONE canonical
configuration: `cargo build --example gen_builtins --features curl`. That is
the only configuration in which the exporter can see both halves of ext/curl —
the shared catalog's PHP-visible `curl_*` contracts and Magician's matching
eval bindings — so the previous default-feature build was structurally unable
to document 34 functions the compiler fully supports.

- extract.py refuses to run against a default-feature exporter instead of
  silently emitting a catalog 34 rows short, and routes prelude entries to the
  prelude that actually declares them (curl_prelude.rs, not hash_prelude.rs).
- render.py names that prelude from the extracted lowering rather than
  hard-coding "hash".
- registry.py gains curated curl param/return types transcribed from the
  prelude's own PHP declarations, on the hash_* precedent; curl_version() and
  curl_multi_info_read() stay `mixed` because the prelude declares no return
  type for either.
- audit_builtins.py pins the 47 non-registry routes and audits every curl row
  for AOT-through-prelude plus an eval registry binding.
- gen_php_comparison.py gains NEWER_THAN_BASELINE for PHP functions that
  postdate the vendored 8.4 snapshot, so the two PHP 8.5 curl additions are
  reported rather than miscounted. The curl coverage row goes 0/33 -> 32/33.

Regenerated: 621 registry rows (587 + 34), 1160 pages. Idempotent.
…cords

The hash_init fixture was a hand-built dict with no lowering block, so it
exercised the renderer's prelude-neutral fallback rather than the naming path.
Feed both prelude families their committed registry records instead, and assert
curl pages name the curl prelude.
…re on

curl-feature-contract gains the two root-level suites the new relay makes
runnable: the cross-backend parity integration test and the compiler's
prelude-signature parity lib test. Neither needs a native libcurl — they read
builtin metadata and never reach Magician's eval dispatch table, so nothing
references elephc_curl_* — which keeps the job cheap and always-on.

builtins-docs-sync now builds the exporter with --features curl, so the drift
gate runs in exactly the configuration that produced the committed pages.

Both jobs were already in the test umbrella's needs and assertions; no new job.
actionlint: 4 findings, same 4 as the HEAD baseline.
The catalog_curl module doc, elephc-magician's feature header, the punch-list
ledger and the ROADMAP all described a world where no configuration could see
the curl surface: no parity coverage, no generated docs, only the hand-written
docs/php/curl.md. The root relay and the feature-on docs configuration make all
of that stale. docs/php/curl.md now links the generated per-function pages.
The leading-character check allowed a space before `function <name>(`, so a
prose mention on a comment line could have been picked up as the declaration
(and `find` takes the first match). Require the match to be preceded by nothing
but whitespace on its own line.
…t the inverse

The prelude parity test recorded only whether a parameter had a default, never
which one, and never read the declared PHP type. Compiled code executes the
default the prelude writes and eval() executes the catalog's, so a prelude
`float $timeout = 5.0` against a catalog Float(1.0) left every registry, both
coverage audits and the generated docs green while curl_multi_select($mh)
waited five seconds in one backend and one in the other. The catalog side has
the committed registry JSON as a review backstop; the prelude side had none.

Compare the default's value (floats and ints parsed, not string-matched) and
the declared type (compatibility, since TypeSpec has no object/array vocabulary
— but a Mixed contract may not be narrowed to a scalar in the prelude). Handle
PHP variadics explicitly: `...$rest` carries no `=` and would otherwise read
as a missing default. Negative-controlled on a drifted default and a drifted
type, both reverted.

Add the inverse audit: a PHP function a catalog-hosted prelude declares without
a contract is invisible to both parity suites and to the generated docs, since
all of them iterate contracts(). curl_file_create is the one instance and is now
permanently excluded by a named allowlist entry carrying its rationale, checked
in both directions. Scoped to the hash and curl preludes, and to configurations
whose catalog slice is published — the other five preludes ship PHP surfaces
with no shared contracts at all by design.

Restate what the integration suite's eval signature comparison proves: eval_builtin!
submits only a contract ID and EvalBuiltinSpec::from_binding derives the whole
signature from eval_signature(contract), so assert_signature_shape compares the
contract with itself. That is main's single-source-of-truth architecture making
eval signature drift unrepresentable, not a hole — but it is an existence check,
and the doc comment said drift detection.

Also compare the curl by-reference set as a BTreeSet so a catalog reorder cannot
fail for a non-reason.
…rface

Two silent fallbacks in the docs extractor. An unmapped contract area fell
through to hash_prelude.rs and a missing declaration fell through to line 1, so
a prelude-provided contract added in a third area would have shipped a
generated, committed page naming the wrong prelude and linking to an unrelated
line. Both now raise, and the hash prelude gets its own explicit area entry
instead of being the accidental default.

The canonical-configuration guard also diagnosed every count mismatch as a
stale default-feature binary. Only zero-found means that; any other count means
the surface changed size and the pinned constant needs bumping, which is now
what the message says.
The ROADMAP line read as if the cross-backend suite detected catalog-vs-eval
signature drift. It cannot: Magician derives its signature from the contract, so
that drift is unrepresentable rather than detected. Name what the suite does
prove, and credit the hand-written signature that genuinely is audited — the
prelude's PHP declaration, now including declared types and default values —
plus the new inverse audit.
nghttp2 failed to build on all three CI platforms: make ran the autotools
regeneration chain (am--refresh -> aclocal-1.16) and died with Error 127 on
runners that have no automake.

Root cause is the staging layer, not the recipe. extract_tar_gz never applied
the tar header's mtime, so each file inherited the instant it was written and
the relative age of any two files became a function of their position in the
archive. Upstream ships aclocal.m4 deliberately NEWER than configure.ac and
m4/*.m4 so the regeneration rules stay dormant (measured on nghttp2 1.70.0:
14:29:47 vs 14:29:37), but aclocal.m4 sits at tar entry 74 and configure.ac at
576, m4/* at 1581+ — so extraction inverted exactly the relationship upstream
set up.

Every extracted regular file now carries one fixed past instant. GNU make
rebuilds only on a STRICTLY newer prerequisite, so an all-equal tree cannot
trigger any regeneration rule in any package — this closes the class, not the
instance, and needs no recipe revision bump: mtimes enter neither ArtifactKey
nor the receipt (which hashes size + content), so no lockfile churn either.
A fixed past value rather than now() also keeps two extractions of the same
verified tarball identical, and keeps configure's generated output strictly
newer than its sources.

Why this never showed locally: Apple ships GNU Make 3.81, whose 1-second
timestamp granularity collapses a fast extraction into a single second. The
failure is therefore timing-dependent, not deterministic. Reproduced by
restoring the inverted ordering with a >1s spread — Error 127 on aclocal.m4,
identical to CI — and cleared by equalizing them.

curl and libssh2 carry AM_MAINTAINER_MODE and were immune; verified by building
both from inverted-mtime trees with no automake present, not by reading the
grep.
Second CI red wave on the curl shards: every codegen::curl::eval::*
fixture failed with an offline crates.io resolution error, then every
other fixture sharing that process died with a poisoned-lock
PoisonError -- including pure-AOT fixtures unrelated to eval.

Root cause 1: libelephc_magician_curl.a (the curl-aware magician
archive eval()+curl fixtures need) was never prebuilt or archived like
every other bridge, so ensure_magician_curl_staticlib always fell
through to an on-demand `cargo build --features curl`, which cannot
work in a shard job running from an extracted nextest archive with
CARGO_NET_OFFLINE=true and no cargo registry cache. Build it in the
build-archive-<platform> jobs (same isolated --target-dir recipe the
harness itself used to use on demand), ship it via a new
[[profile.ci.archive.include]] entry, and make
ensure_magician_curl_staticlib trust the prebuilt copy (and fail
loud-and-fast if it's somehow still missing) instead of ever
attempting the doomed on-demand build when ELEPHC_TEST_PREBUILT_BRIDGES
is set.

Root cause 2: BRIDGE_STATICLIB_BUILD_LOCK's poisoning made one
fixture's build failure crash every other fixture that later locked it,
regardless of whether their own bridges needed a build at all. Recover
from poisoning the same way elephc-curl/elephc-pdo/elephc-image already
do for FFI locks (F-QUAL-02): the guarded payload is (), so there is no
shared state a panic could leave inconsistent, and recovering is
always safe. The original failure stays its own loud, attributed test
failure; it just stops cascading into unrelated ones.

Reproduced both defects locally (direct binary invocation, offline env,
empty scratch CARGO_HOME) before fixing, and verified both fixes the
same way, including a negative control for the lock recovery.
The codegen harness turned each managed native package into a -l NAME and
guarded it with the same dedupe set every other named input shares. Every
streams:: fixture calls fopen($path, ...) with a non-literal filename, whose
requirements include SystemLibrary("z") (builtins/requirements.rs), so -lz was
already planned before the curl block -- and the dedupe then dropped the managed
zlib entirely. Nothing after libssh2.a could resolve the deflateInit_/deflate/
deflateEnd that libssh2.a(comp.o) needs for SSH channel compression.

That failed only on Linux because GNU ld scans archives once, left to right,
while Apple's ld64 re-scans to a fixed point: the plan was wrong everywhere and
observable in one place. Hence 49/52 green on Linux with only streams:: red.

Fixed by making the harness do what the production planner already does --
LinkItem::managed_archive with exact paths. Managed archives then never enter
the named dedupe set, so no unrelated requirement can suppress or reorder them,
and catalog order is preserved by construction. It also closes a quieter hazard
in the same code: a -lssl resolved through the search path could have bound a
system OpenSSL into fixtures whose whole purpose is proving the pinned build.

Production was NOT affected -- managed packages have always been exact paths
there -- but the property was only ever an accident of the implementation, so it
is now pinned by a test as well. Both tests negative-controlled: reverting the
harness to -l names fails all three new order tests, and mutating the production
planner to dedupe managed archives drops libz.a exactly as CI reported.
…dary

`__rt_array_free_deep` reserved 24 bytes for its three spill slots after
`push rbp` had already put rsp on a 16-byte boundary, so every `call` in its
body ran 8 bytes off and handed the callee a stack System V x86_64 forbids.

That was invisible while the callees were all hand-written assembly touching
only integer registers. curl is the first thing that carries the misalignment
into C: a CurlHandle released as an ARRAY ELEMENT walks
__rt_array_free_deep -> __rt_decref_any -> __rt_mixed_free_deep ->
__rt_curl_easy_free -> the elephc_curl bridge, and libcurl faults on the first
aligned SSE spill onto that stack. Measured on linux-x86_64: both
`elephc_curl_easy_free` entries arrive at rsp % 16 == 0 before this change and
at the required 8 after it.

That is the SIGSEGV in
codegen::curl::multi::multi_get_handles_reports_add_order_and_tracks_removal,
and only there: it is the one AOT fixture whose handles are freed out of arrays
($handles/$left) rather than out of a local or a property. aarch64 was never
affected — its emitter already reserved 32.

Pinned by the_deep_free_chain_keeps_sysv_call_alignment_on_x86_64, which
generates the linux-x86_64 runtime on any host and asserts every call on the
deep-free chain lands on a 16-byte boundary.
…he rule

The previous commit fixed __rt_array_free_deep, the one helper of this class
that curl actually reached. Seven more carried the identical signature — a
`push rbp` followed by a `sub rsp` that is not a 16-byte multiple, so every
`call` below hands its callee a stack System V forbids:

  __rt_array_chunk 40->48        __rt_range 40->48
  __rt_array_chunk_refcounted    __rt_json_encode_array_int 40->48
  __rt_array_pad_refcounted      __rt_spl_fixed_new 24->32
  56->64                         __rt_zval_pack_element 56->64

x86_64 arms only; spill-slot offsets and aarch64 untouched.

MEASURED, NOT ASSUMED: these were latent, not live. A probe running
array_chunk/array_pad/range over two live CurlHandles on linux-x86_64 reports
every bridge entry aligned even with the frames still wrong, because
__rt_array_push_refcounted's subtree (ensure_unique -> clone_shallow, incref,
push_int -> grow) contains no decref at all and so cannot reach
__rt_mixed_free_deep's resource ladder. Fixed anyway: an ABI violation one
refactor away from mattering is not worth leaving in place.

The rule is now enforced globally rather than helper by helper.
runtime/sysv_call_alignment.rs renders the linux-x86_64 runtime for both
feature profiles, walks every helper's CFG tracking rsp, and fails on any
misaligned `call`. Helpers that still violate are named WITH their reason and
the list shrinks only; helpers the walk cannot model are named as holes, and
one that is neither fails the build. The eleven helpers a CurlHandle travels
through into the bridge may never be allowlisted at all.

Building the audit found what a narrower test would not: sections are not
functions (the emitters lay private subroutines behind the exported helper, so
nine __rt_date_* bodies and __rt_bcmath_binary were never being walked), which
in turn exposed two more real violations in __rt_date and __rt_strtotime.

Of the 20 allowlisted helpers, three reach code this runtime did not write and
are the ones worth fixing next: __rt_usort and __rt_array_udiff_uintersect
`call r12` into the user's compiled-PHP comparator, and __rt_fiber_entry calls
libc setjmp. The last is deliberately left alone — coroutine entry, frame
load-bearing for __rt_fiber_switch's stack hand-off, needs fiber tests first.
…output

The eval process builtins ended in
`.output().map(|o| o.stdout).unwrap_or_default()`, so a process that could
not be created AT ALL produced the same empty byte string as a command
that ran and printed nothing. exec() then answered "" for a command it
never executed.

That is the curl-feature-contract CI failure. On a loaded runner
posix_spawn fails with EAGAIN and one exec() in an otherwise-passing test
silently returned "" while its siblings succeeded. Measured directly: 2560
concurrent `/bin/sh -c "printf spread"` spawns under `ulimit -u 900`
produced 1177 EAGAIN failures, every one of which the old code reported as
"printed nothing".

The reported call shape is not implicated. exec-direct and
exec-through-call_user_func_array converge on the same
eval_process_command_result -> eval_shell_command_output after argument
binding, so nothing on the call-array path can make them differ; which
call sees the failure is only which spawn lost the race. Nor is there
toolchain skew: every CI job installs dtolnay/rust-toolchain@stable and
the repo pins none, so curl-feature-contract compiles with the same
stable as the rest.

Two changes, both in the runner:
- EvalShellOutcome distinguishes Ran(bytes) from SpawnFailed, and
  SpawnFailed answers false — php-src's own failure value (php_exec
  returning FAILURE ends in RETURN_FALSE). The Ran arm is byte-for-byte
  the behaviour this file always had.
- A transient spawn failure (EAGAIN/ENOMEM) is retried up to five times
  with doubling backoff. Safe because it is a SPAWN failure: the child was
  never created, so nothing can run twice. The classifier matches the raw
  errno as well as ErrorKind, since that mapping has moved between Rust
  releases and CI compiles with whatever stable is current.

Regression tests are deterministic and need no process pressure: an
over-long argument makes posix_spawn fail with E2BIG every time, on every
machine. That test asserts Bool(false) and fails with String("") against
the old runner. Its negative control pins that a command which genuinely
ran and printed nothing keeps every return value it had — without it, a
"report false whenever output is empty" fix would pass while breaking
system()/passthru(), whose successful return IS the empty-output shape.
…mped

eval_tmpfile_path() built its "unique" name from SystemTime::now().as_nanos(),
but CLOCK_REALTIME advances in whole microseconds: every caller inside one tick
got the same number. Two threads opening an ephemeral eval stream in the same
tick therefore built the identical path, and the loser's create_new failed with
AlreadyExists in the window between the winner's create and its remove_file.
open_ephemeral_stream swallows that with .ok()?, so an eval fopen("php://memory")
answered false for a stream with nothing wrong with it -- and the resource-id
tests that opened one either saw a shifted id sequence or a RuntimeFatal from the
next builtin applied to a non-resource. Looping the magician test binary reproduced
it in 13 of 800 runs, and 0 of 150 with --test-threads=1.

A process-wide AtomicU64 sequence now joins the clock, so no two calls in a
process can collide however many threads ask at once. The clock and the pid stay:
the sequence restarts at zero in each process, so they are what separate one run
and one process from the next.

The regression tests force the interleaving with a barrier instead of hoping for
it -- 8 threads realigned every round -- and both were verified to fail against
the old code (1649 distinct paths out of 2048; 274 of 2048 opens returning None).
The third test is the negative control: it pins the temp directory and the pid,
so a "fix" that used a bare global counter with a fixed name would fail it. The
barrier helper documents why its bodies must not panic: an unwinding thread never
reaches the next wait and hangs its siblings instead of failing the test.
`store_cstr` wrote each string result into a process-global `Mutex<CString>`
and returned `guard.as_ptr()`. The assignment drops the `CString` the cell
held, freeing the bytes any pointer handed out of that same cell still points
at, and the guard is gone by the time the caller reads. The mutex serializes
the write; it cannot extend the lifetime past the read.

`cargo test -p elephc-pdo -- --ignored` runs the live PostgreSQL and MySQL
round trips concurrently and both read SQLSTATE constantly, so one of them
regularly read a freed 6-byte block that the allocator had already reused --
CI's `my_round_trip` asserted `"00000"` and got non-UTF-8 garbage. Reproduced
locally at 45/300 runs against mysql:8.4 + postgres:16 containers.

Every result buffer whose pointer escapes to the caller is now thread-local,
the treatment `COLDATA_CELL` already had: the 26 `Mutex<CString>` cells, and
`blob_cell` with its readers and writers. A thread only invalidates pointers
it was itself handed, and the prelude copies each one into an owned PHP string
before its next bridge call. The handle tables and the ODBC diagnostic cell
stay process-global -- they hand out copies, not interior pointers.

The documented contract narrows from "valid until the next call" to "valid
until the next call on that thread", which is strictly weaker for callers; no
symbol, signature, or return value moved, so the prelude and generated code are
untouched. `sqlstate_buffers_are_isolated_between_threads` pins it without a
database and fails 50/50 on the old code.
Closes the class the PDO SQLSTATE fix opened. Three more bridges handed the
caller a `*const u8` / `*const c_char` into a process-global `Mutex<..>` cell
and released the lock on the way out. Refilling such a cell drops or
reallocates the buffer, freeing the exact bytes a pointer already in flight
points at, so any second thread touching the same cell invalidates the first
one's pointer -- the mechanism that reached CI as non-UTF-8 garbage in place of
a SQLSTATE.

- elephc-tz: `stash` and its three `CString` cells (transitions, location,
  abbreviations).
- elephc-image: the staging and encode cells in `codec.rs` and the in/out cells
  in `xfer.rs` (the two `*_ptr` entry points hand out a WRITE pointer, so a
  concurrent `resize` there frees a buffer the prelude is still writing into),
  plus the per-operation state drained by later calls on the same thread: the
  probe result, the kv list, the float matrix, the polygon points, the IPTC
  groups, and the thumbnail metadata.
- elephc-phar: `EXTRACT_BUFFER`, whose pointer `publish_result` returns.

Handle registries stay process-global on purpose: `images()`, `draws()`,
`WRITE_STREAMS`, the curl/tls/pdo tables and the magician caches are keyed by
an id the program holds and hand out owned values, never an interior pointer.

No ABI change: the three crates export the same 211 `extern "C"` functions with
the same signatures; only the lifetime of the bytes behind the returned
pointers narrows, from "until the next call" to "until the next call on that
thread", which is strictly weaker for callers.

One cross-thread guard per crate pins it, each verified failing on the pre-fix
code: `location_buffers_are_isolated_between_threads` (3/3),
`encode_buffers_are_isolated_between_threads` (3/3), and
`extract_buffers_are_isolated_between_threads` (5/5, with distinct sizes AND
fill bytes so either publish order is detected).
The PDO SQLSTATE use-after-free (garbage bytes for "00000" under concurrent
tests) came from a static Mutex<CString> handing out as_ptr() -- the next call
from any thread freed what an earlier caller was still reading. The class was
swept out of pdo/tz/image/phar; this makes the sweep durable: the rule in
CONTRIBUTING.md's bridge-ABI section and AGENTS.md's conventions, enforced by
tests/ffi_buffer_hygiene.rs (walks crates/ and src/, flags lock-around-buffer
statics through path qualifiers and Lazy/OnceLock wrappers, spares the id-keyed
registry shape, shrink-only reasoned allowlist -- empty today).
`inject_if_used` / `inject_if_used_for_version` now take the
`PreludeInventory` every sibling prelude takes and record the parsed
declarations under the `curl` group, so declaration reachability knows
which declarations the compiler contributed rather than the program.

The curl prelude stays PARSE-AT-INJECT while the rest of the stdlib is
now built in Rust. The deviation is stated at the injection site with
the reason (PDO's conversion needed the transcriber plus the
node-by-node oracle across every profile, and this surface is ~2,000
version-fenced lines) and the cost it does and does not carry: only a
compile that reaches curl pays the tokenize/parse.
`--with-curl` force-injects the whole surface for a program whose only
route to curl is one the compiler cannot see. Declaration reachability
then deleted every one of those declarations again, because nothing in
the program reaches them: measured on a curl-free fixture, 40 functions
in and 1 out, with `curl_init` and `CurlHandle` both gone. Listing
`curl` in `forced_groups` alongside `pdo`/`tz`/`image` is what makes the
flag mean what it says.

Also pins, with the measurement behind it, why NO curl declaration is
registered with `record_internal_callable_method`. PDO registers four
methods whose generic callable syntax only invokes closure descriptors
the prelude itself created. Curl's two dynamic sites are the opposite:
`curl_setopt()` asks `is_callable($value)` about the USER'S value and
`__elephc_curl_sync_read_slot`'s dispatcher `call_user_func()`s the
user's read callback. A callback named by bare string reaches the
prelude through a `mixed` parameter that no scan connects to the
declaration, so those hazards are the only thing keeping it alive —
clearing them would silently delete a callback the program still calls.
A third test enumerates the hazard sites, so a future genuinely internal
dispatch is a failure asking the question rather than a silent answer.
The stdlib preludes are now built in Rust and their `*_PRELUDE_SRC`
constants are gone, so the source-text prelude audits had nothing left
to read. Both halves survive rather than one replacing the other:
`injected_prelude_programs` is the single list every audit iterates, and
the two that need DECLARATION TEXT render it back to PHP through
`synthetic_class::print` — a rendering `printing_round_trips` re-parses
and compares node for node, so an assertion made against it is as strong
as one made against hand-written source.

curl is the one member that is parsed rather than built, and it is in
the list for that reason: a source-text prelude left out of an
AST-shaped audit is silently unaudited rather than loudly unconverted.
The extension-builtin call-site gate therefore covers it again, which
main's conversion had dropped.

`php_type_matches` grows the `TypeSpec::Ptr` and `TypeSpec::Callable`
arms. Neither is a PHP scalar and neither belongs in `Mixed`'s open
surface: both have exactly one spelling a prelude may use, so they
compare like the scalars and are excluded from `Mixed`'s.
`__rt_report_uncaught_exception` now drains the output buffers before
exiting, so it has a `call` for the first time — reached through an
`and rsp, -16`. The walk tracks rsp as an exact offset from the entry
and a hard realignment has no such offset, so the helper defeated it and
was reported as an unnamed hole.

It joins `__rt_closure_bind`, which realigns the same way. Naming it is
the honest outcome rather than a weakening: the list exists to say what
is NOT checked, and a `call` after `and rsp, -16` is the one construct
that cannot be misaligned whatever the path in.
`src/curl_prelude.rs` is the last prelude that tokenizes and parses
embedded PHP at injection time. The line records the conversion path PDO
took — transcribe, keep the source as the migration oracle, gate on the
node-by-node comparison across every version profile — so the deviation
is a scheduled piece of work rather than an omission.
A prelude declares a PHP function in one of two shapes now: the
`function("hash_copy")` builder chain every stdlib prelude uses, or the
`function hash_copy(...)` source line `curl_prelude.rs` still ships. The
extractor knew only the second, so with the stdlib preludes built in
Rust the four `hash_*` pages resolved to `src/hash_prelude.rs:1` — an
anchor pointing at the file's module doc rather than the declaration.

The branch's loud failure is what surfaced it: matching neither form is
now an error rather than a line-1 fallback, so this could not stay
silent. Both patterns still reject a longer identifier ending in the
name, keeping `__elephc_curl_easy_body` from answering for
`curl_easy_body`.

Regenerated through the generators in the canonical `--features curl`
configuration; the four pages and their registry rows now carry real
line numbers, and everything else comes back byte-identical.
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