feat: expand PDO parity across maintained PHP drivers#559
Conversation
…heap-guard, Mixed-receiver dispatch register PDO finalization (spec #511): - elephc-pdo bridge ABI v6 -> v7: connection/statement SQLSTATE, statement error accessors, boolean/blob binds, a busy-timeout setter, server-version reporting, and a text-valued last-insert id (Postgres sequence-safe). - PDO prelude: existing W1-W6 surface plus the PHP 8.4 driver subclasses Pdo\Sqlite / Pdo\Mysql / Pdo\Pgsql, declared block-form `namespace Pdo { class Sqlite extends \PDO {} ... }` so the running namespace never leaks onto appended user code. - detect.rs: recognize two-segment `Pdo\<driver>` names (case-insensitive, exact depth) so a program referencing only a subclass still injects the prelude; `App\Sqlite` and `Vendor\Pdo\Sqlite` are not matched. - Codegen tests for subclass injection, ctor inheritance, and inherited constants (existence proven via constant access, not instanceof, which the checker does not validate); docs/php/pdo.md updated. Web per-request heap reset + double-free guard: - __rt_web_reset resets the PHP heap arena to bump-only state as its final step, after every refcounted per-request value has been released; the PDO persistent-connection pool and bridge cells live outside _heap_buf and are unaffected. - Cheap small-bin double-free guard for --web builds (_web_heap_guard_enabled) routes a detected double free to __rt_heap_debug_fail, exiting the worker so the prefork master respawns it rather than aborting the whole server. - Prefork worker-lifecycle web tests (workers, max-requests, max-body-size, gzip, graceful shutdown / worker reaping). Mixed/union-receiver method dispatch: - Reserve the nested-call scratch register (x19 / r12) for method- and callable-dispatch receivers held across argument lowering, with matching frame-layout save slots when a function uses it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PHP 8.4 `PDO::connect($dsn, $username, $password, $options)`: dispatches on the
DSN driver prefix (sqlite: / mysql: / pgsql:) and returns an instance of the
matching Pdo\Sqlite / Pdo\Mysql / Pdo\Pgsql subclass, each of which inherits the
whole \PDO surface. An unrecognized prefix throws
PDOException("could not find driver").
Declared to return the base \PDO because the subclasses ARE \PDO and elephc has
no `static` return type; the runtime object is the exact subclass, so
`PDO::connect("sqlite:...") instanceof \Pdo\Sqlite` is true. Divergence: the DSN
prefix, not the receiver class, selects the driver, so a subclass-qualified
mismatched call is not rejected as PHP would.
Tests (pdo codegen 69/0): functional sqlite connect + instanceof both classes,
unknown-driver throw, and PDO-typed return threading through a \PDO parameter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, escapeIdentifier Toward 100% PDO coverage (pure-prelude tier, no new bridge/compiler work): PDOStatement (PHP 8.4 additions): - public $queryString property, threaded from prepare(). - getAttribute / setAttribute (per-statement attribute store). - nextRowset() -> false (elephc drivers expose a single rowset per statement). - getColumnMeta(): column name + PDO/native type from the bridge accessors, false for an out-of-range column (full len/precision/flags metadata is a bridge-tier follow-up). - debugDumpParams(): SQL + bound-parameter count to stdout. Driver subclasses: - All 30 PHP 8.4 driver-specific constants: Pdo\Sqlite (DETERMINISTIC, OPEN_*, ATTR_*), Pdo\Mysql (16 ATTR_*, mysqlnd set), Pdo\Pgsql (ATTR_*, TRANSACTION_*). - Pdo\Pgsql::escapeIdentifier(): PQescapeIdentifier semantics (a pure string transform, no server round-trip) — the first driver-specific method. Builtins in the namespaced method body are \-qualified. Tests (pdo codegen 74/0): own-constants access, statement queryString/attributes/ nextRowset, getColumnMeta, debugDumpParams, and escapeIdentifier exercised via a non-connecting Pdo\Pgsql subclass (empty ctor + dtor) so the pure method runs without a live PostgreSQL server. docs/php/pdo.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ker-verified) Two connection-backed driver-subclass methods, verified against live PostgreSQL and MySQL in Docker: - Pdo\Pgsql::getPid(): int — the backend process id (SELECT pg_backend_pid()). - Pdo\Mysql::getWarningCount(): int — warnings raised by the last statement (SELECT @@warning_count). Reliable immediately after a direct exec()/DML statement; the pure-Rust client does not surface a SELECT's EOF-packet warnings, and an intervening prepared-statement COM_STMT_CLOSE resets the session count. Bridge (elephc-pdo ABI v7 -> v8): - pg.rs PgConn::backend_pid(), my.rs MyConn::warning_count(), and the elephc_pdo_backend_pid / elephc_pdo_warning_count externs (each returns 0 for a connection of a different driver or an unknown handle). Prelude: - A protected PDO::connectionId() accessor exposes the private connection handle to driver subclasses through normal inherited method dispatch; getPid/getWarningCount call it (externs \-qualified inside the Pdo namespace). Tests: live #[ignore]d fixtures in pdo_pgsql.rs / pdo_mysql.rs (constructing the driver subclass directly), run against Docker with ELEPHC_PG_DSN / ELEPHC_MY_DSN (2 passed). SQLite pdo suite unaffected (bridge change is driver-specific; 74/0 at the Tier-A build). docs/php/pdo.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rified) Pdo\Pgsql large objects and COPY, verified against live PostgreSQL in Docker: - lobCreate(): string|false — SELECT lo_create(0), returns the new OID as text. - lobUnlink(string $oid): bool — SELECT lo_unlink(<oid>). - copyFromArray() / copyFromFile() — COPY … FROM STDIN via the postgres copy_in writer; the prelude builds the COPY statement (default tab/\N text format, with an optional column list and DELIMITER/NULL overrides) and streams the joined rows / file contents. - copyToArray() / copyToFile() — COPY … TO STDOUT via copy_out; the bridge returns the raw output as a string that the prelude splits into rows / writes to a file. No C-side array marshalling: array shaping stays in the PHP prelude. Bridge (elephc-pdo ABI v8 -> v9): PgConn::lob_create / lob_unlink / copy_in / copy_out and the matching elephc_pdo_* externs, plus a shared pg text-result cell. COPY runs inside a closure so the writer/reader borrow of the client ends before the connection's error bookkeeping is written. Tests: live #[ignore]d fixtures (lobCreate/lobUnlink; copyFrom/toArray roundtrip) against Docker — pg/mysql live suite 21/0; SQLite pdo suite 74/0; 0 warnings. docs/php/pdo.md updated. Gotcha applied: elephc's checker does not narrow string|false after a `=== false` guard, so file_get_contents() / lobCreate() results are cast to string before the extern / method call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…idge v10) - Pdo\Sqlite::loadExtension(string $name): void — loads a SQLite extension by path (entry point auto-derived), throwing PDOException on failure. Extension loading is enabled only for the call and disabled again afterward; it runs native code from the named library, so it weakens the standalone-binary guarantee (documented). - PDOStatement::getColumnMeta() native_type now reports the column's DECLARED type (sqlite3_column_decltype), e.g. "INTEGER" / "TEXT", matching PHP, falling back to the value-type name for an expression column with no declared type. Bridge (elephc-pdo ABI v9 -> v10): SqliteStmt::column_decltype, SqliteConn:: load_extension, and the elephc_pdo_column_decltype / elephc_pdo_load_extension externs (both via bundled libsqlite3-sys raw FFI, which exposes sqlite3_column_decltype / sqlite3_load_extension / sqlite3_enable_load_extension). Tests (:memory:, no Docker): loadExtension throws on a nonexistent path; getColumnMeta native_type = declared type. Default pdo suite 75/0, 0 warnings. docs/php/pdo.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-verified) - Pdo\Pgsql::getNotify(int $fetchMode = 0, int $timeoutMilliseconds = 0): array — polls for a pending LISTEN/NOTIFY notification, returning [channel, pid, payload] or an empty array if none arrives within the timeout. Backed by the postgres crate's notification iterators (timeout_iter for a nonzero timeout, iter for a buffered non-blocking poll). Divergences (documented): returns an empty array rather than false for "none" (both falsy, so `while ($n = $db->getNotify())` still terminates), and always returns the numerically-indexed (FETCH_NUM) [channel, pid, payload] shape regardless of $fetchMode — elephc's EIR array backend cannot return a string-keyed array alongside an empty array from one method's return paths. Bridge (elephc-pdo ABI v10 -> v11): PgConn::get_notify + the elephc_pdo_get_notify extern (returns channel\tpid\tpayload, empty if none within the timeout). Tests: live #[ignore]d fixture (LISTEN + NOTIFY + getNotify) against Docker — pg/mysql live suite 22/0; SQLite pdo suite 75/0; 0 warnings. docs/php/pdo.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…read-whole BLOB/LOB streams (v12)
PDO Tier-D — createCollation, the callback-trampoline subsystem.
A PHP callable cannot cross the C ABI, so at the prelude layer each callable is
decomposed into two raw pointers: its 64-byte descriptor (__elephc_callable_ptr —
closures / first-class callables only) and the address of a shared codegen adapter
(__elephc_pdo_adapter_addr). Both cross as plain `ptr` args, so no bridge extern
ever declares a `callable` and the checker's C-callback gate is untouched.
Per-registration state is threaded through SQLite pApp (a bridge-owned
Box<UdfReg>{descriptor, adapter}), so any number of collations coexist on one
connection — replacing the old single process-global callback slot (last-write-wins,
only one live UDF per program). The bridge x_compare re-enters PHP only through the
delivered adapter address and never references a __rt_* symbol (respecting the
non-whole-archive linker boundary).
__rt_pdo_call_collation (new runtime family, dual-arch aarch64/x86_64, gated by
RuntimeFeatures::pdo_udf) boxes the two transient SQLite byte buffers as owned Mixed
strings (deep-copied via __rt_str_persist, so the buffers may be freed on return),
builds the uniform invoker's boxed indexed-array container, invokes offset-56, and
casts the boxed return to the comparison sign.
Exception firewall: a compiled-PHP `throw` is a longjmp; without interception it
would unwind over SQLite's VDBE and the Rust bridge frame (mutex deadlock / UB /
process exit). The adapter wraps the invoke in its own setjmp handler (the EIR
try/catch 224-byte record layout, survivor snapshot so cleanup stops at the C
boundary) — on a throw it pops the handler, swallows the pending exception (SQLite
xCompare has no error channel), and returns 0 (equal), leaving the connection usable.
Surfacing the exception at the query boundary and dropping the connection mutex
across a re-entrant UDF are deferred hardening (Slice 4).
Internal pointer builtins __elephc_callable_ptr / __elephc_pdo_adapter_addr (String /
Array callables rejected at lowering, since their value is a PHP string not a
descriptor). __rt_pdo_call_collation is address-taken through the GOT with the raw
symbol name (not Target::extern_symbol, which would add Mach-O's leading underscore a
runtime asm label does not carry).
Also lands the previously-uncommitted read-whole BLOB/LOB streams (bridge v12):
Pdo\Sqlite::openBlob / Pdo\Pgsql::lobOpen read a BLOB / large object whole into a
rewound php://memory stream via elephc_pdo_blob_read / elephc_pdo_lob_get, drained
NUL-safely by elephc_pdo_blob_byte; a shared protected PDO::blobStream() helper wraps
it. Read-whole snapshots: reads work with embedded NUL bytes; writes are not flushed.
Bridge elephc-pdo ABI v11 -> v13 (v12 streams, v13 create_collation); version test
brought to version_is_v13.
Tests (macOS-aarch64): createCollation reverse-order, two collations coexisting
(disproves the single-slot bug), throwing-comparator-does-not-hang (firewall);
SQLite openBlob; runtime symbol-presence + feature-omission; macOS dead-strip runtime
still assembles. pdo suite 79/0 (23 live #[ignore]d), elephc-pdo crate 11/0, 0
warnings. Live #[ignore]d pg lobOpen unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…idge v14)
Registers compiled-PHP closures as SQLite scalar SQL functions, extending the
Tier-D "decompose-at-PHP" callback subsystem from collations (Slice 1) to scalar
user functions. The callable is split at the prelude into a descriptor pointer
(__elephc_callable_ptr) plus the shared codegen adapter address
(__elephc_pdo_adapter_addr(1) -> __rt_pdo_call_scalar), so the bridge extern
receives two plain `ptr` args and never a `callable`.
Codegen adapter (__rt_pdo_call_scalar, dual-arch aarch64 + x86_64):
- Dynamic per-arg boxing loop over the bridge's ElephcVal[] (40-byte stride):
SQLite INTEGER->Mixed int, FLOAT->Mixed float (f64 bit-pattern in the integer
lo reg), TEXT/BLOB->Mixed string (deep-copied via __rt_str_persist; no separate
blob tag, binary-safe), NULL->Mixed null. Boxed into a value_type-7 indexed args
array (0-arg functions use the empty header-only array path).
- Type-preserving return decode: __rt_mixed_unbox once, dispatch the tag ->
ElephcResult (int/float/bool/null direct; string bytes staged into the bridge
via elephc_pdo_udf_stash_bytes before the owned box is released).
- Same 224-byte setjmp exception firewall as the collation adapter: a throwing
UDF longjmps back, is swallowed, and reported as ElephcResult.tag = -1, which
the bridge turns into sqlite3_result_error (no unwind across SQLite's VDBE +
the Rust bridge frame). The boxing loop uses only caller-saved temporaries
(x10/x9-x15 on aarch64, r8-r11 on x86_64) so the adapter never clobbers a
callee-saved register the bridge's x_scalar caller may hold live across the call.
Bridge (crates/elephc-pdo): ElephcVal/ElephcResult #[repr(C)] ABI, x_scalar
dispatcher (recovers UdfReg via sqlite3_user_data since xFunc has no pApp),
SqliteConn::create_function via sqlite3_create_function_v2 (reusing x_destroy),
per-thread result stash for string/blob returns (SQLITE_TRANSIENT). Bridge ABI
v13 -> v14.
Prelude: Pdo\Sqlite::createFunction(string, callable, int $numArgs = -1,
int $flags = 0): bool. udfCallbacks keys are now namespaced ("collation:" /
"function:") so a same-named collation and function cannot evict each other's GC
root.
Validation (aarch64): 7 new e2e tests (int args, string round-trip, float +
zero-arg, two-coexist [disproves single-slot problem C], per-row dynamic typing,
null round-trip, throwing-UDF-no-hang) all green. Full PDO suite 86/0, bridge
crate 11/0, main lib 647/0, macOS dead-strip runtime assembles, 0 warnings. A
3-lens adversarial audit of the dual-arch adapter (x86 ABI / refcount-lifetime /
aarch64 symmetry) surfaced and fixed the caller/callee-saved register issue above;
no other defects found.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (bridge v15)
Registers compiled-PHP step + finalize closures as SQLite aggregate SQL functions,
completing the Tier-D "decompose-at-PHP" callback subsystem for SQLite (collation +
scalar + aggregate). Each callable is split at the prelude into a descriptor pointer
plus a shared codegen adapter address (__elephc_pdo_adapter_addr(2) -> agg step,
(3) -> agg final), so the bridge extern takes four plain `ptr` args and never a
`callable`.
Per-group accumulator threading:
- AggReg (two descriptor+adapter pairs) boxed as pApp, recovered by both dispatchers
via sqlite3_user_data; freed by a DEDICATED x_destroy_agg (reusing UdfReg's
x_destroy would mis-size the Box free).
- AggCtx { row_count: i64, accumulator: *mut } (16 bytes) lives in
sqlite3_aggregate_context — zeroed on the first step, the same block for every step
+ the final within a group, auto-freed by SQLite at group end. xFinal reads it with
nBytes 0 so an empty group (no step) yields a NULL slot -> null accumulator, row 0.
Codegen adapters (dual-arch aarch64 + x86_64):
- __rt_pdo_call_agg_step(descriptor, accumulator, rownumber, argv, argc, threw)
-> new_accumulator: boxes [accumulator, rownumber, ...rowValues] (slot 0 increfs a
non-null accumulator / boxes PHP null on the first row; row values via the scalar
boxing loop offset by 2), invokes inside the setjmp firewall, and returns the owned
new accumulator. Normal path releases the args container (dropping the slot-0
incref) then the old accumulator's group ref; throw path preserves the accumulator
(so xFinal frees it), sets *threw = 1, returns null.
- __rt_pdo_call_agg_final(descriptor, accumulator, rownumber, out): boxes
[accumulator, rownumber], decodes the return exactly like the scalar adapter, and —
finalize being terminal — frees the accumulator on BOTH the normal and throw paths
(and the fast no-invoker path). A thrown finalize is reported as out.tag = -1.
- Both use only caller-saved scratch registers (the x21 lesson from Slice 2) and the
shared 224-byte firewall.
Prelude: Pdo\Sqlite::createAggregate(string, callable $step, callable $finalize,
int $numArgs = -1): bool, rooting both callables under distinct udfCallbacks keys.
Bridge ABI v14 -> v15.
Validation (aarch64): 6 new e2e tests (sum, string-concat, GROUP BY per-group
isolation, empty group, throwing-step, throwing-finalize) all green. Full PDO suite
92/0, bridge crate 11/0, main lib 647/0, macOS dead-strip assembles, 0 warnings.
A 3-lens adversarial audit of both adapters (x86 ABI / accumulator-refcount /
aarch64 symmetry) found NO defects. A --heap-debug analysis proved the accumulator
incref/decref protocol is leak-neutral: an N-arg aggregate step leaks identically to
an N-arg scalar (the residual ~1-block-per-arg growth is the pre-existing uniform-
invoker leak, not introduced here).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation callbacks Updates docs/php/pdo.md to reflect the now-implemented SQLite callback methods (createCollation / createFunction / createAggregate): moves them from "not yet provided" / Limitations into the supported surface, adds a dedicated section with signatures + a worked example, and documents the load-bearing behaviors — closures / first-class callables only, the throw-is-caught-at-the-C-boundary firewall semantics (a throwing collation compares as equal; a throwing function/aggregate fails the statement with a PDOException), the per-invocation dynamic-dispatch heap retention, and that re-entering PDO from inside a callback is unsupported (a nested query returns no rows rather than deadlocking). The only remaining callback method, Pdo\Pgsql::setNoticeCallback, is called out as still missing. No code change. The Slice-4 "lock-drop" hardening item was investigated and found unnecessary: a UDF that re-enters PDO on the same connection does not deadlock (exec holds conns(), step holds stmts(); the nested path degrades to an empty result rather than re-locking fatally), verified with a watchdog repro. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the last Tier-D callback method: a callback dispatched for each PostgreSQL server NOTICE (RAISE NOTICE etc.). Unlike the SQLite UDFs, this needs no codegen trampoline — the callback is invoked from PHP (the prelude), so it is a plain dynamic call. Bridge (crates/elephc-pdo/src/pg.rs): PgConn::open now builds the connection through a `postgres::Config` (rather than Client::connect) so it can install a `Config::notice_callback` that buffers every AsyncMessage::Notice's message text into a shared `Arc<Mutex<VecDeque<String>>>`. `drain_notice()` pops one, and the `elephc_pdo_get_notice` C-ABI wrapper (lib.rs) exposes it (mirroring get_notify's string-return pattern). Bridge ABI v15 -> v16. Prelude (Pdo\Pgsql): setNoticeCallback(callable $callback): void stores the callback; exec()/query() are overridden to call the parent then drain + dispatch any buffered notices; a constructor seeds a no-op callback so draining always has a callable. Two deliberate divergences from PHP, both documented (docs/php/pdo.md): - The parameter is a NON-nullable `callable` (not `?callable`): elephc's checker cannot narrow a nullable-callable property back to a callable at the invocation site (and a `?callable` param lowers to Mixed, which prop_set cannot assign to the Callable-typed property), so the callback is stored in an untyped property seeded with a no-op closure — inferred as Callable and invocable. To stop delivery, register a no-op rather than passing null. - Delivery is poll-based (drained after each exec()/query()) rather than fired mid-protocol, so a NOTICE from a prepared-statement execute() lands on the next exec()/query(). Validation (macOS): compiler + bridge build 0 warnings, bridge version_is_v16, full PDO codegen suite 92/0 (the shared prelude compiles the new Pgsql constructor + methods on every test), and a Pgsql setNoticeCallback + exec + query program compiles and lowers to a binary. Runtime behavior requires a live PostgreSQL server and is validated in the end-of-campaign Docker pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ification Implements PDO_PARITY_AUDIT_SPEC.md: fetch/statement surface, driver behaviors and 8.4 bug-for-bug semantics validated against php-src and a live PHP 8.5 CLI. Prelude (src/pdo_prelude.rs): - fetch/query/bindParam/fetchAll signature parity (bounded fallbacks where method-variadics are checker-broken) - FETCH_NAMED duplicate-column grouping, fetchColumn OOB -> ValueError, STRINGIFY_FETCHES, getIterator, FETCH_CLASS|PROPS_LATE accept - getColumnMeta native_type + sqlite:decl_type + BLOB via eager pre-step - connect-failure errorInfo, getAttribute CLIENT_VERSION/CONNECTION_STATUS - mode-validation guards -> ValueError; FETCH_BOUND no-op via stepCursor() - fail-loud for language-infeasibles (FETCH_FUNC, bindColumn, pre-ctor FETCH_CLASS order, bare FETCH_LAZY, clone) - PDOException::$errorInfo typed ?array (fixes untyped Mixed-tag corruption) Bridge (crates/elephc-pdo, C-ABI v16 -> v18): - sqlite: 60s busy-timeout, ATTR_OPEN_FLAGS + file: URI DSN, readonly stmt, aggregate $rownumber PHP-8.4 semantics (step 1..N, finalize N+1, empty 1) - mysql: BIGINT UNSIGNED > i64::MAX -> numeric string (Docker-verified), INIT_COMMAND, charset via SET NAMES, connect_timeout - pgsql: credentials precedence, getNotify assoc, connect_timeout Drops 9 sqlite constants that are PHP-8.5-only (verified absent in 8.4 stub). Docs: TLS/SSL not yet supported for pg/mysql (deliberate standalone posture), FETCH_CLASS untyped-property caveat, PDOException 2nd-param, FETCH_LAZY notes. pdo suite 133/0, bridge crate 21/0/2 (v18), 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ql-tls) Adds TLS to the network PDO drivers via rustls, C-ABI v18 -> v19 (elephc_pdo_open_persistent gains a my_ssl_config parameter). PostgreSQL TLS ships in the default `tls` feature, ring-provider only, so the default build stays aws-lc-rs-free (musl-friendly, matching elephc-tls); `cargo tree -i aws-lc-sys` finds nothing in the default graph. Configured through the DSN's own libpq keys: - pg.rs strips sslmode/sslrootcert/sslcert/sslkey from the libpq connection string (tokio-postgres rejects the file keys and the verify-* sslmode values) and applies them via a rustls MakeRustlsConnect built with an explicit ring CryptoProvider (no process-global default needed). - sslmode disable -> plaintext, prefer (default) -> opportunistic, and require/verify-ca/verify-full -> TLS with the server certificate always verified against the bundled webpki roots or a custom sslrootcert PEM (stricter and safer than libpq's bare `require`). sslcert+sslkey enable client-certificate mutual TLS. MySQL/MariaDB TLS is behind the opt-in `mysql-tls` feature: the `mysql` crate's rustls backend pulls rustls with its default aws-lc-rs provider (no lever to force ring), which would otherwise drag the aws-lc C/asm toolchain into every PDO binary. When enabled it installs the ring provider as the process default (once) to arbitrate the two providers the mysql crate's provider-less ClientConfig::builder() would otherwise panic on. - The prelude packs Pdo\Mysql::ATTR_SSL_CA/CERT/KEY/VERIFY_SERVER_CERT into my_ssl_config; my.rs parses it into SslOpts (root cert, client identity, danger flags). A default (no mysql-tls) build fails loud when TLS is requested rather than silently connecting in plaintext. Docker-verified end to end with a self-signed CA and a SAN'd server cert: pg verify-full/require succeed against the CA and are rejected without it; mysql (mysql-tls build) verifies the CA, is rejected on a wrong CA (UnknownIssuer), and a plaintext attempt is refused by require_secure_ transport. Adds unit tests (DSN/TLS parsing, the ring connector builder, the fail-loud mysql path), codegen tests (ATTR_SSL_* constants, inert for sqlite), and #[ignore]'d live round-trip fixtures for both drivers. pdo suite 135/0, bridge crate 28/0 (default and mysql-tls), 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…w narrowing Fix two compiler bugs surfaced by the PDO parity audit. Deep array strict equality: `array === array` (including `array|false === []`) compared array payloads by heap-pointer identity, so it was always false for distinct allocations. Add `__rt_array_strict_eq` implementing PHP `===` deep structural equality (equal element count, same key => value pairs in insertion order, values recursively strict-equal) with a unified packed/hash logical iterator (`__rt_array_iter_next`), so a packed indexed array and a hash compare correctly. `__rt_mixed_strict_eq` now routes array tags (4/5) to the deep helper (and lets a packed-vs-hash pair through the tag gate); `lower_strict_eq` routes two concrete array operands to it directly, before the `lhs_ty != rhs_ty` early-out. Also fix a latent x86-64 SysV stack misalignment in `__rt_mixed_strict_eq` (it called sub-helpers at rsp = 8 mod 16 with no `push rbp`), which the new recursive call would have propagated. Flow narrowing: the checker recognized only is_int/is_float/is_string/is_bool and instanceof, so `array|false` / `?array` values could not be narrowed for `count()` etc. Recognize `is_array`, `is_null`, and the strict comparisons `$x === null` / `$x === false` (and `!==`, in either operand order) via a new `GuardTarget` enum. Verified: macOS ARM64 full codegen suite 5275/0 (0 warnings); x86-64 Docker "strict" tests 66/0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sqlite/mysql/pgsql) Implement the PDO parity audit v2 (7 P0, 11 P1, 15 P2, 9 P3), verified against php-src and validated live (SQLite in CI; PostgreSQL 16 and MariaDB via Docker). Bridge ABI v19 -> v22. Data-corruption / correctness (P0): - mysql: read last_insert_id()/affected_rows() before draining the result, so exec() no longer reports 0 affected rows; CALL statements keep their first row (column_count() returns a placeholder for an un-executed CALL so the prelude picks the result-set branch instead of the DML branch). - mysql: detect binary columns by ColumnType (BLOB/BIT/GEOMETRY) and charset 63 (VARBINARY/BINARY), so BIT and *BINARY values round-trip instead of decoding to U+FFFD. - Bind text with an explicit length (sqlite3_bind_text(..., len)), so values containing NUL bytes are no longer truncated at the first NUL. - Placeholder scanners no longer mistake comment bodies (`--`, needing trailing whitespace for mysql), dollar-quoted / E'' strings, double-quoted identifiers, or `??` for parameters, and copy UTF-8 string literals byte-exact. Conformance (P1/P2/P3): - execute($params) records the passed binds and gates recorded binds on a null argument, matching PDO's merge semantics. - pgsql: strip TLS keys and apply a libpq conninfo allow-list after TLS parsing; honour ATTR_TIMEOUT as connect_timeout; raise HY093 on mixed placeholder styles; copyToArray() distinguishes an empty result from a failure. - Validate ERRMODE / default fetch mode / ATTR_CASE; ATTR_CASE column folding and ORACLE_NULLS transforms; quote() bytea / _binary / NO_BACKSLASH_ESCAPES branches; getColumnMeta negative index, nextRowset IM001. - ABI v22 adds elephc_pdo_bind_text length, elephc_pdo_no_backslash_escapes, and elephc_pdo_in_transaction externs (with version_is_vNN guards). Verified: SQLite pdo suite 172/0, bridge lib 61/0, macOS full codegen suite 5275/0, 0 warnings; live PostgreSQL 16 and MariaDB round-trips via Docker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…22→v26)
Implement PDO_FINALIZATION_SPEC_V3.md (an 8-agent audit of elephc's PDO against
php-src PHP-8.4, ~84 unique findings) across four waves plus a final adversarial
audit and live validation. Bridge ABI v22 -> v26.
Wave 1 — correctness (P1):
- PDO::exec("")/query("") now raise ValueError before any driver call (query()
reports under its own method name), matching php-src.
- setAttribute() validates the value SHAPE (TypeError) before the range check, so
setAttribute(ATTR_ERRMODE, "banana") no longer casts to 0 and silently switches
the connection to ERRMODE_SILENT.
- getColumnMeta() pgsql pdo_type: OIDOID(26) groups with BYTEA -> PARAM_LOB, not
with the integer family (php-src pgsql_statement.c switch).
- mysql: sql_is_call_statement() skips leading comments, so `/* hint */ CALL p()`
is routed to the result-set branch and no longer drops the procedure's first row.
- Bind index/name validation (ValueError) + HY093 for an unresolved placeholder;
PARAM_* flag masking before dispatch; PARAM_BOOL uses the real per-driver bool
bind; the 7 legacy PDO::SQLITE_* base-class constants.
Wave 2 — bridge robustness + perf:
- ffi_guard/lock_recover panic firewall over every extern (a poisoned mutex or a
panic crossing the C ABI degrades to the documented sentinel instead of an abort).
- columnValue()/blobStream() bulk-copy through ptr_read_string instead of one FFI
call per byte.
- default connect_timeout of 30s for pgsql and mysql; sqlite Drop safety nets;
ATTR_EXTENDED_RESULT_CODES wired; pg scanner fixes (greedy colon runs,
non-ASCII dollar-quote tags).
Wave 3 — conformance:
- serialize(PDO)/serialize(PDOStatement) throw; driver-subclass DSN guard; mysql
credential precedence (ctor args win, pg keeps DSN-wins); getAttribute IM001
(setAttribute returns false silently — php-src parity, verified against 8.5.6);
uri: DSN; pdo_drivers(); PARAM_EVT_*; ATTR_FOUND_ROWS; unix_socket honored only
for localhost; persistent-pool key includes the ATTR_PERSISTENT string.
- fetch() adopts php-src's signature (2nd arg is cursorOrientation, not a class
target); real FETCH_CLASSTYPE (class from column 0's value, column 0 consumed,
stdClass fallback); FETCH_LAZY rejected in fetchAll() not fetch(); FETCH_INTO
HY000 when no target; flag-masked setFetchMode gates; FETCH_GROUP/FETCH_UNIQUE
implemented; COPY delimiter truncated to one byte; getColumnMeta reports pg's
table_oid/len/precision and MySQL's real native_type names.
Wave 4 — remaining items + docs + CI:
- setFetchMode(FETCH_COLUMN, non-int) TypeError; debugDumpParams per-parameter
blocks; readonly $queryString; PDOException carries $previous and populates
$code; pg native errcode is a named documented constant (PostgreSQL has no
integer code — SQLSTATE is the code, already in errorInfo[0]).
- docs/php/pdo.md rewritten against the code, enumerating every divergence.
- .github/workflows/pdo-live.yml: opt-in/nightly job running the live pg16 +
MariaDB #[ignore] suites (they had never run in CI); env-var families unified.
Fixes a silent DSN-corruption regression the ABI growth introduced: growing
elephc_pdo_open_persistent to 7 args made the extern-call lowering's 4th string
alloc recycle the constructor's already-freed DSN buffer; resolveDsnUri() now
forces the returned DSN to own its buffer.
Verified: macOS ARM full codegen suite 5332/0/76 (0 regression outside PDO),
elephc-pdo crate 87/0, 0 warnings; live PostgreSQL 16 and MariaDB round-trips via
Docker (the commented-CALL first-row-drop regression is guaranteed fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent-encoding, fetchAll(FETCH_COLUMN) reset Follow-up to f623778, closing the actionable residue from the v3 final audit. - lastInsertId() returns string|false (F-CORE-18): the bridge reports a driver error / unsupported call as "", which was silently handed back as an empty string. It is now the failure sentinel — the connection's real SQLSTATE is surfaced error-mode-aware (else a generic IM001) and false is returned. The PostgreSQL bridge now records the driver error on a lastval() failure so the real SQLSTATE (e.g. 55000) propagates instead of being swallowed. - Credential percent-encoding (P1 / F-CORE-02): the constructor user/password are folded into the DSN text the bridge parses, which splits on ';'. A ';' (or '%') in a credential was silently truncated -> wrong-credential connect. The fold now percent-encodes '%'->'%25' then ';'->'%3B' on the value only, and both DSN parsers percent-decode ONLY the user/password values, leaving the ';'-splitter and every other key byte-identical (host/dbname with '\' or '%' unaffected). - fetchAll(PDO::FETCH_COLUMN) with no explicit index now resets the value column to 0 (php-src: arg2 ? val : (GROUP ? 1 : 0)) instead of leaking the index from a prior fetchAll(FETCH_COLUMN, $n). - docs/php/pdo.md: document that a literal '?' written as '??' is unsupported under MySQL native prepares (elephc is always-native; php-src defaults to emulation). Verified: elephc-pdo crate 90/0, PDO codegen suite 231/0/61, 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ash-read register bug Two fixes, one uncovered by diagnosing the other. PDO FETCH_GROUP was O(n^2). Appending a row to a group's bucket read the bucket out of the map, pushed, and wrote it back — and because the read leaves the bucket at refcount 2 (the map slot plus the local), the push's __rt_array_ensure_unique COW-cloned the whole bucket on EVERY row: 1+2+...+n. Writing the shorthand `$_groups[$k][] = $row` would not have helped — the parser eagerly desugars any nested append into exactly that read/push/write-back triple, so both forms compile to the same IR. The fix needs no compiler change: unset() the map slot before the push, dropping the bucket to refcount 1 so ensure_unique short-circuits and the push mutates in place (amortized O(1)). Output order is unaffected — the result is assembled from the separately-kept first-seen key list, never from the map's iteration order. Diagnosing why isset()/array_key_exists() were unavailable there (which is why the existence test is a count() probe) surfaced a real, latent memory-corruption bug: `__rt_array_get_mixed_key`'s x86_64 hash branch read __rt_hash_get's results as if they mirrored the ARM64 convention (rsi=value_lo, rdx=value_hi), but the real x86 ABI is rax=found, rdi=value_lo, rsi=value_hi, rcx=value_tag — rdx is never written by __rt_hash_get, so a garbage pointer was boxed into the returned Mixed cell and SIGSEGV'd on first deref. This hit every x86 read of an `$arr[$k]` whose array is statically Array(Mixed)/Array(Union) but has runtime-promoted to hash storage, far beyond PDO. The ARM64 branch was always correct, which is why aarch64-only CI could not see it. Fixed by dropping the two bogus movs (rdi/rsi already sit in __rt_mixed_from_value's expected input registers straight out of __rt_hash_get). Verified: macOS ARM full codegen suite 5336/0/77, 0 warnings; PDO suite 232/0/61 with every pre-existing FETCH_GROUP/FETCH_UNIQUE test unchanged; the new mixed-key hash-read regression test passes on Linux x86_64 (the case that used to SIGSEGV) and on ARM64. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Started as two deferred array gaps and turned into a bug hunt. Every fix below is
reproduced, fixed and pinned by a regression test. Full macOS suite 5358/0/77, zero
warnings.
## Foundational: array parameters are now passed BY VALUE
`function f(array $a) { $a[] = 1; }` mutated the CALLER's array. PHP implements array
value semantics with refcount + copy-on-write, but the call site handed the callee a +0
BORROW, so `__rt_array_ensure_unique` (which only splits at refcount >= 2) stayed inert
and every write landed in the caller's storage. All three write paths were affected
(mixed key, int key, append).
Three distinct causes:
- The inliner's cleanup exclusion was a NO-OP. It marked transplanted callee param slots
`HiddenTemp` to keep the host epilogue away from them — but
`local_kind_needs_epilogue_cleanup` sweeps `HiddenTemp` too. The host released a
borrow it never acquired: an `array` param merely READ in a loop died with
`heap debug detected bad refcount` under -O and was clean under -O0. Replaced with a
real `Function::no_epilogue_cleanup_slots` the epilogue honours.
- Params did not own their arrays. `privatize_container_param` now re-binds each
by-value container param to an owning shadow slot at function entry, emitting exactly
what an ordinary `$b = $a;` already emits (LoadLocal + Acquire + StoreLocal). No new
op, no new assembly. One hook in `lower_body_into_function` covers free functions,
methods, static methods, closures, `call_user_func`, dynamic calls and recursion.
`release_owned_call_arg_temporaries` now tests ownership PER PARAMETER: an `iterable`
param keeps its own runtime shape, is NOT privatized, and still needs its pass-through
alias guard. The inliner refuses callees with a by-value container param (the shadow
would leak N-1 in a host loop).
- A generic `array` hint froze on its FIRST call site. The widening machinery existed but
was gated on `!declared_params[i]` — and `array` is, technically, declared. So a body
compiled for raw int slots later received an `Array(Mixed)` (boxed cell pointers) and
read back a POINTER. `union_param_type` gained an (Array, Array) element-join arm, and
the gate now admits array-typed declared params, discarding the generic hint exactly
once (mirroring the existing Int-fallback discard).
TRAP, documented in the tests: a direct read of the caller's array after the call is
CONSTANT-FOLDED and prints the right answer while the runtime mutates underneath. Every
caller-side assertion must launder its read through a function call. That is why 5355
tests coexisted with this bug.
## Silent data loss / memory corruption
- `$g["k"][] = $row` DROPPED every row of every new bucket (count() == 0, no warning).
Nothing auto-vivified the missing inner array, so the first push read back a boxed null
and `MixedArrayAppend` discarded the value. New `ir_lower::stmt::nested_append` fuses
the parser's read/push/write-back desugar: isset -> vivify if absent -> read ->
`Op::SlotDetach` -> push -> write-back. Also takes it from O(n^2) to O(n) (measured:
allocations 250 rows -> 513, 1000 rows -> 2015; frees == allocs).
- `self::$b[$k][] = $v` dropped the append and OVERWROTE the bucket. The parser strips the
trailing `[]`, so the `if is_append` guard sat on an arm that could never match.
- A hash write through a reference-bound local discarded the reallocated table pointer:
`hashes::source_load_local_slot` recognized only `Op::LoadLocal`, not `Op::LoadRefCell`
like its indexed twin. A 41-key hash built through a ref reported a garbage count.
- `$a[$k] = $v` with a `mixed` key DESTROYED the array. `__rt_array_set_mixed` re-stamped
the destination's value_type to boxed-Mixed without converting the slots already in it,
so `[1,2,3]` came back with slot 0 a real cell and slots 1-2 raw integers behind a header
claiming they were pointers. Reading `$a[1]` dereferenced the integer `2` and SEGFAULTED.
- `isset($a[$intKey])` / `array_key_exists()` on an `Array(_)` receiver promoted to hash
storage read the hash header's `head` field as an element pointer (SIGSEGV). An
`Array(_)`-typed local can be hash-backed at runtime, so every packed-payload walk now
dispatches on the storage-kind byte.
## Mixed-key probes (the original gap)
`isset()` / `array_key_exists()` now work for a Str/Mixed/Union key on an indexed
receiver, via a new storage-kind-dispatching `__rt_array_key_exists_mixed_key` helper.
`array_key_exists` answers true for a null-valued key while `isset` answers false, so the
presence probe cannot be built from the read helper plus an is-null check.
## Ownership / leaks
- `Op::ArrayGetMixedKey`/`Silent`/`ArrayGetSilent` return an OWNED Mixed cell but were not
classified as owning container reads: one cell leaked per read and per isset probe.
- `__rt_array_set_mixed_key` now receives an OWNED array (the lowering acquires it, like
`Op::ArrayToHash`); its promote paths release the source they abandon, and `store_local`
releases the slot's previous occupant. The in-place and already-hash paths are now
heap-clean. NOTE: the decref is only safe BECAUSE of the acquire — without it, it is a
use-after-free, which is why this lands as one commit.
## x86_64 ABI
`__rt_array_key_exists_mixed_key` and the pre-existing `__rt_array_get_mixed_key`
clobbered `r12`/`r13` — callee-saved under SysV, and `frame.rs` reserves `r12` to hold a
method receiver across argument-lowering calls. `$obj->m($arr[$k])` corrupted the receiver.
Invisible to CI, which is macOS-aarch64 only. Also fixed the missing float arm in
`materialize_mixed_hash_key` (a Mixed key holding 2.7 became integer key 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ property boxing Four fixes, each uncovered by the previous one. Full macOS suite 5363/0/77, zero warnings. ## SIGSEGV: `Op::ArrayGet` was blind to the storage kind An `Array(_)`-typed local can be HASH-backed at runtime: a mixed-key write promotes the storage kind while the checker only promotes the STATIC type to `AssocArray` at a provably string-keyed write. `Op::ArrayIsset` and `array_key_exists` were given a storage-kind dispatch in 8d65eba; `Op::ArrayGet` was not — and it is the one that actually CRASHES, bounds-checking the index against the hash header's live-entry count and then reading the header's own fields as elements. `function get(array $a, mixed $k) { return $a[$k]; }` segfaulted. It now dispatches on the kind byte and reads through `__rt_hash_get`, materializing the SAME representation the packed path produces, so the op's result type is unchanged. An array with no element type keeps the packed-only path (the hash-value materializer has no representation to produce for `Void`/`Never`). ## A `Mixed`-typed key now reads the RIGHT element `$a[$k]` with a `mixed` key `str_to_i`-coerced the key before the read: `"foo"` became `0` and it silently returned element 0. The obvious fix — routing the key to the mixed-key op — was NOT taken, because that op retypes the read's RESULT to a boxed `Mixed`, which breaks a downstream typed assignment like `Iterator $it = $this->iterators[$i]`. That is not a corner case: `$i++` lowers to `Op::IChecked*`, whose PHP result type is `Mixed`, so EVERY incremented loop counter is statically `Mixed` from its second use on. Instead the key keeps `Op::ArrayGet` — whose result stays the array's element type — and is simply no longer coerced; the codegen materializes it on both storage kinds. PHP's numeric-string key rule comes free from `materialize_hash_key`: `"1"` IS the integer key 1, while `"foo"` is a genuine string key, which never exists in packed storage. The gate is the IR type, NOT the PHP type. `Op::IChecked*` reports a PHP type of `Mixed` while its runtime value is a RAW INTEGER; only a genuinely boxed `Heap(Mixed)` cell may skip the coercion. ## Nested append vivifies on property and static-property bases `$this->b[$k][] = $v` and `self::$b[$k][] = $v` dropped the first row of every bucket, exactly like the local base did: nothing created the missing inner array. The fusion now recognizes both, and vivifies through the ordinary `PropertyArrayAssign` / `StaticPropertyArrayAssign` lowering — the very one the group's own write-back uses. It must be that lowering and not a bare `$t = []`: the append temporary's checker type is the container's VALUE type (typically `Mixed`), so a raw `Array(Never)` literal bypasses the boxing the storage expects and the bucket segfaults the moment it outgrows its initial capacity. These bases deliberately do NOT get `Op::SlotDetach`: that op republishes the possibly-rehashed container pointer through `source_load_local_slot`, which resolves a LOCAL slot and nothing else — on a property the new pointer would never reach the property and it would be left stale. Trading a data loss for a dangling pointer is not a fix. They are correct but still quadratic; the local base keeps its O(1). ## A store into a `Mixed`-typed property was NOT boxed An UNTYPED property becomes `Mixed` as soon as two different types are assigned to it. A `Mixed` slot holds a boxed cell — but `Op::PropSet` was handed the value exactly as lowered, so `$this->pos = 0` wrote a RAW integer into it, and reading it back dereferenced that integer as a cell pointer: `$this->pos = 0; var_dump($this->pos);` printed **NULL**. It survived because ANOTHER bug masked it perfectly: the old array-read path coerced a `Mixed` key with `__rt_mixed_cast_int`, and casting that null cell gives `0` — so `$names[$this->pos]` read element 0 and returned the right answer, by accident. Fixing the read is what exposed it. Two bugs propping each other up. Now routed through `coerce_typed_assign_value`, the helper that already did exactly this for typed assignments. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nt corruption, SIGSEGV)
An op is lowered against the local-type environment that holds AT THAT POINT. An indexed-array
element write whose value type is incompatible with the current element type emits `Op::ArrayToMixed`
and re-types the local to `Array(Mixed)` — and at runtime `__rt_array_to_mixed` REPLACES every element
slot with a boxed Mixed-cell pointer. Any op lowered against the old representation but executed after
the conversion therefore receives a CELL pointer where it expects an ARRAY pointer, or stores a raw
scalar into a slot that now holds a cell: silent corruption, or a SIGSEGV.
This lands the two reachable-by-control-flow halves of that defect.
BACK EDGE (loops). A loop body is lowered exactly once, against the ENTRY env, so a widening write at
the bottom invalidates every op above it from iteration 2 on:
$m = [1, 2, 3];
for ($i = 0; $i < 3; $i++) { $m[0] = 9; $m[1] = "s"; }
var_dump($m[0]); // PHP: int(9) elephc: SIGSEGV
`lower_loop_at_type_fixpoint` discovers what a body widens by lowering it speculatively, rolls that
back, pre-widens the discovered locals in the loop PREHEADER, and re-lowers. Rollback is exact:
`Builder::snapshot_function` clones the whole `Function` (a length-truncation is unsound — lowering
MUTATES surviving entries), and `LoweringSnapshot` destructures `LoweringContext` field by field with
no `..` rest pattern, so a field added later fails to compile until its rollback is decided. The
closure sink is truncated and `closure_counter` restored, so a re-lowering regenerates identical names
instead of emitting a duplicate closure. Fast path: with no concretely-typed indexed array in scope,
no snapshot and no speculative lowering happen at all.
BRANCHES (if/elseif). `lower_if_chain` restored `initialized_slots` at the arm split but never
`local_types`, so the then-arm's widening LEAKED into the else arm — and the always-on tail-sinking
pass copies the post-`if` code into every arm, where it was compiled against the leaked type while
that path's array was still raw. Ordinary PHP, with no loop at all, silently lost output:
function f(int $c): void { $m = [$c, 2, 3]; if ($c > 0) { $m[1] = "s"; } echo $m[0], "\n"; }
f(1); f(0); // PHP: 1 then 0 elephc: 1 then NOTHING
Three coupled parts: restore `local_types` at the split; join at the merge through deferred arm-tail
blocks (an already-terminated arm cannot be patched — `assert_can_append` panics — and a `return`/
`throw`/`break`/`continue` arm has no edge into the merge at all); and record widenings STICKILY,
without which the first part regresses a program that passes today only via the very leak it removes.
Also fixes a regression from d99d38c that CI could never have caught: the speculative promoted-hash
branch of `ArrayGet` was emitted for `?int` (`TaggedScalar`) elements, which hash storage cannot
represent on either side of the lookup, failing the whole compile. It surfaced only in
`ir_backend_smoke_test` — a binary the codegen suite does not cover. And `examples/pdo/main.php`
called `fetch(PDO::FETCH_CLASS, ContactRow::class)`, which is a TypeError in PHP 8.4; it now uses
`setFetchMode()`.
Full suite, all seven binaries: 8730 passed / 0 failed, zero warnings. x86_64: 395 / 0.
Known still broken, and deliberately not papered over (tests are `#[ignore]`d with the reason):
`switch`, `try`/`catch`, `match` and short-circuit/ternary regions reach the same defect, and a second
conversion axis exists (`Array` -> `Hash`) that loses data SILENTLY. A naive entry pre-widen does NOT
generalize to them: `__rt_array_to_mixed` rewrites slots in place at refcount 1, so hoisting the
conversion corrupts a sibling operand already lowered in the enclosing expression. The loop preheader
is immune only because it both dominates AND precedes the region it guards, and the body is re-lowered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/match/ternary + Array→Hash) An op is lowered against the local-type environment that holds at that point. A representation-changing array conversion re-types a local mid-lowering AND, at runtime, rewrites the array's storage — so any op lowered against the old representation but executed after the conversion misreads memory: boxed Mixed-cell pointers read as raw scalars (SIGSEGV), or packed indices read out of a hash table (silent data loss). 3bce110 fixed the two control-flow doors reachable inside a single statement's straight-line + back-edge + if-merge shape. This closes the rest. There are TWO conversion axes, not one: `Op::ArrayToMixed` (Array(T)→Array(Mixed), boxes every slot) and `Op::ArrayToHash` (Array(_)→hash, replaces the packed vector). And the divergence is reachable through EVERY conditionally-evaluated construct — switch fall-through, try/catch landing pads, match arms, ternary, and short-circuit && / || — each of which lowered ops against a representation a later path invalidates. Ordinary PHP with no loop at all was affected: function p(int $c): void { $m = [$c, 20, 30]; switch ($c) { case 1: $m["k"]=5; echo "one\n"; break; case 2: echo "two ", $m[1], "\n"; break; default: echo "def ", $m[1], "\n"; } echo "after ", $m[1], "\n"; } p(2); p(1); p(9); // PHP: two 20 / after 20 / one / after 20 / def 20 / after 20 // before: values silently vanished on the c==2 and c==9 paths (Array→Hash) $x = ($c === 1) ? ($m[0] = "s") : "n"; // before: SIGSEGV / empty output; now == PHP The fix canonicalizes at STATEMENT granularity. `lower_stmt` routes every statement through `repr_fixpoint::lower_stmt_at_type_fixpoint`: discover what the statement converts by lowering it speculatively (an `is_speculating` guard keeps nested cost linear, not exponential), roll that back exactly via `LoweringContext::snapshot`/`restore`, canonicalize the discovered locals at the statement's ENTRY, then re-lower. The statement is the smallest region that both DOMINATES and PRECEDES every op it contains — no operand of a statement precedes the statement's entry, so re-lowering re-lowers the operands too. This is why hoisting the conversion to a *construct's* own entry (a switch's, a match's) is UNSAFE and this is not: `__rt_array_to_mixed` rewrites slots in place at refcount 1, so a conversion hoisted above an already-lowered sibling operand corrupts it — `h($m, match ($c) { 1 => $m[0]="s", default => "d" })` at $c=0 stays correct precisely because arg0 is re-lowered against the canonicalized env. Both axes share ONE predicate (`types::array_storage::array_storage_conversion`), used by the lowering to pick `ArrayToMixed` vs `ArrayToHash` AND by the checker's conditional-arm env merge (`merge_array_storage_effects`), so a callee's compiled element type can never drift from what the caller passes. `join_array_storage_conversion` resolves a local converted on both axes on different paths to a hash (a hash arm entered with merely-boxed slots would write through the wrong layout). Cost is gated by a purely syntactic, EXHAUSTIVE AST pre-scan (`may_convert_array_local` / `ConversionScan`): a candidate array in scope, a mutation naming it, and a conversion-hiding construct. A straight-line statement skips the fixpoint entirely — real programs compile in 0.04–0.06s (measured); the only slowdown is on synthetic deep if-chains, and that is the pre-existing DCE tail-duplication being exponential, not the fixpoint. The scan's `match` is exhaustive so a new StmtKind/ExprKind fails to compile rather than silently defaulting to "safe". Also fixes a pre-existing silent corruption this generalizes to: `if ($c) { $m["k"]=1; } h($m);`. Verified against PHP: switch fall-through (was SIGSEGV), switch+string-key Array→Hash (was silent data loss), ternary, && , try/catch, match, and the anti-regression program — all exact. Logic suites green: codegen_tests 5387/0, error_tests 999/0, ir_backend_smoke_test 255/0, lib 647/0, bins 678/0, lexer 205/0, parser 349/0. Excluded, deliberately (unchanged): not-definitely-initialized / ref-bound / global-storage locals stay out of canonicalization (the conversion dereferences the slot); `static $s=[1,2,3]` + a widening write still fails to compile — pre-existing, orthogonal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve code (php-src parity)
A failed sqlite query threw `SQLSTATE[HY000]: no such table: t`, where PHP throws
`SQLSTATE[HY000]: General error: 1 no such table: t`. Two pieces were missing from the message: the
SQLSTATE class DESCRIPTION ("General error") and the native driver code (1). php-src's
`pdo_handle_error` builds a driver error as `SQLSTATE[%s]: %s: %d %s` (state, description, native,
driver message); elephc emitted only `SQLSTATE[%s]: %s`.
Adds `__elephc_pdo_sqlstate_description()` to the prelude, mirroring php-src
`pdo_sqlstate_state_to_description` (ext/pdo/pdo_sqlstate.c) for every SQLSTATE the sqlite/pgsql/mysql
bridges emit (HY000, 23000, 42S02, 42S22, 42000, 22001, 01002, 08006, HY093, HYC00, IM001), with
php-src's own `<<Unknown error>>` fallback for an unrecognized state. Both `PDO::fail()` and
`PDOStatement::fail()` now interpolate the description and the native code (`elephc_pdo_errcode` /
`elephc_pdo_stmt_errcode`) into the message, in both the EXCEPTION and WARNING branches. `errorInfo`
is unchanged — it already held the raw [state, native, message] triple frameworks read.
Scope is deliberately the DRIVER-error path only. The synthetic `failCode()` path (IM001, HY093, …)
is left untouched: it bakes partial descriptions into some messages ad-hoc, so applying the helper
there would double them and needs a separate call-site cleanup + test sweep.
Verified byte-for-byte against PHP 8.4 + pdo_sqlite:
SQLSTATE[HY000]: General error: 1 no such table: nope
SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: t.id
PDO codegen suite 232/0 (no regressions; the prefix-checking connect tests still pass); new
regression `test_pdo_driver_error_message_has_description_and_native_code` locks the exact shape.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add PHP 8.0–8.6 PDO surfaces, complete statement semantics and driver behavior, and close the remaining callable, hydration, ownership, metadata, transaction, and stream gaps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Balance dynamic object and mixed-property ownership so PDOStatement destruction releases persistent connections reliably. Run PostgreSQL 16 and MySQL 8.4 live suites on pull requests, including CA-verified TLS fixtures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final CI stabilization (
|
CI stabilization —
|
Follow-up: DBLIB port parity and cross-target diagnosticsPushed
Focused local checks passed: cross-target |
|
Nouvelle itération poussée dans Correctifs inclus :
Validation locale ciblée : build sans warning, tests |
Summary
This PR takes the PDO compatibility campaign from the original SQLite/PostgreSQL/MySQL bridge to the complete eleven-driver matrix tracked for supported PHP releases. It also fixes the compiler/runtime defects exposed by the expanded PDO test corpus, adds version-aware API surfaces for PHP 8.0 through 8.6, and introduces build plus live-database CI for the new profiles.
The normative compatibility scope is PHP 8.2 through 8.5. PHP 8.0 and 8.1 remain historical compatibility targets, while PHP 8.6 is treated as a preview surface until upstream stabilizes.
PDO core and versioned surface
--php-versionandELEPHC_PHP_VERSION, with CLI precedence and explicit 8.0 through 8.6 selection.PDO::connect()and the driver-specific subclass construction path.PDO::getAvailableDrivers()andpdo_drivers()only report drivers present in the selected bridge archive.pdo.dsn.*aliases and PHP-compatible INI/environment precedence, includinguri:DSNs and credential precedence.Driver matrix
Default bridge:
Optional profiles:
pdo-libpq-gss: full libpq connection lifetime for PHP-style GSSAPI/Kerberos, service/pass files,PG*defaults, encrypted-key options, and libpq-version-dependent keywords.pdo-dblib: FreeTDS DB-Library withPdo\Dblib, versioned aliases, attributes, and live SQL Server acceptance.pdo-firebird: Firebird wire client withPdo\Firebird, versioned aliases, transactions, attributes, metadata, and live acceptance.pdo-odbc: unixODBC/iODBC driver-manager path withPdo\Odbc, attributes, diagnostics, metadata, and live PostgreSQL-over-ODBC acceptance.pdo-informix: Informix CLI/ODBC profile with the PECL surface and explicit external Client SDK boundary.pdo-ibm: IBM CLI/ODBC profile withPdo\Ibm, PHP-versioned aliases, attributes, and explicit external driver boundary.pdo-sqlsrv: Microsoft PDO_SQLSRV 5.13 profile through ODBC Driver 17/18, including its version availability and complete attribute range.pdo-oci: PDO_OCI through Oracle Instant Client/ODPI-C, including the PHP 8.3 bundled-to-PECL transition.pdo-cubrid: official CUBRID CCI path, including attributes, schema/metadata, scrolling, rowsets, LOBs, quoting, and the extension-specific surface.Compiler and runtime fixes uncovered by PDO
CI and examples
PDO Live Databasesworkflow for PostgreSQL 16, MySQL 8.4, SQL Server 2022, Firebird 5, ODBC, Oracle 23ai, CUBRID 11.4, and the libpq GSS/Kerberos fixture.Documentation
--php-versionandELEPHC_PHP_VERSIONin the CLI reference.src/codegen_supportlayout.Post-merge integration fixes
array_key_exists()checker with its dynamicMixed/Unionruntime dispatch afteris_array()guards.MixedPDO option state across control-flow joins and normalizes session option keys at the helper boundary.__elephc_ptr_read_stringalias and preserves declared method casing in private-access diagnostics.test_pdo_constants_presentplus focused narrowing, prelude-parity, private-factory, and corpus regressions.Validation performed
Focused validation during the campaign includes:
git diff --checkclean.The new GitHub workflows provide the authoritative full supported-target and live-service qualification after push.
Explicit qualification boundaries
Integration with current main
origin/maininto the feature branch and resolves the compiler, EIR, runtime, test-runner, documentation, and CI workflow overlaps.is_array()narrows amixedvalue, joins nullableMixedoption accumulators correctly across conditional loop bodies, and normalizes session keys at the typed helper boundary. This restores PHP 8.4/8.5 session options after the shared version-profile merge.Post-merge focused validation passed:
cargo check, formatting, diff hygiene, PHP-version/CLI/PDO-prelude unit tests, the DCE regression, PDO constants, SQLite exec/fetch, all SQLite callable forms,pdo-cubridandpdo-libpq-gssfeature checks, the mixed-array/control-flow regressions plus the four previously failing PHP web-session scenarios, workflow YAML parsing, builtin extraction, and the complete documentation audits above.