Skip to content

Narrow mixed types across the stubs, fix two value-constructor crashes, ship IDE stubs - #152

Merged
CodeLieutenant merged 3 commits into
trunkfrom
feat/narrow-stub-types-and-ide-stubs
Aug 10, 2026
Merged

Narrow mixed types across the stubs, fix two value-constructor crashes, ship IDE stubs#152
CodeLieutenant merged 3 commits into
trunkfrom
feat/narrow-stub-types-and-ide-stubs

Conversation

@CodeLieutenant

Copy link
Copy Markdown
Member

What this does

Narrows mixed across the stub files, fixes two crashes found while verifying the narrowing, and ships IDE stubs with each release.

Type narrowing

The stubs were already fully typed — nothing was missing. But 71 declarations were mixed, which gives opcache and the JIT nothing to work with. 53 of them are now precise.

The value union is Value|string|int|float|bool|null, derived from ResultDecoder.c: a decoded Cassandra value is a string, int, bool, float, null, or one of 19 objects, and all 19 implement Value.

Highlights:

Method Before After
FutureRows::get() mixed ?Rows
FutureSession::get() mixed ?Session
FuturePreparedStatement::get() mixed ?PreparedStatement
FutureClose::get() mixed null
Rows::current() / first() / offsetGet() mixed ?array
Rows::key() mixed ?int
Map/Set/Collection/Tuple accessors mixed value union
Keyspace::function() / aggregate() $types mixed string|Type

Why narrowing input parameters is safe

The engine computes should_throw from arginfo before the handler runs, then raises a fatal Arginfo / zpp mismatch if the handler returns without throwing (zend_execute.c, zend_internal_call_should_throw). The comment there is explicit: "In release builds, we trust that arginfo matches what is enforced by zend_parse_parameters."

This is safe here because php_scylladb_validate_object throws on every value outside the union and accepts a strict subset of it — so no accepted value can fall outside the declared type, and the check can never fire.

No BC break. An array or foreign object still raises Cassandra\Exception\InvalidArgumentException, not TypeError, because the C handler throws before the engine's check. Verified directly.

What stays mixed, and why

18 declarations remain, each one forced:

  • 10 ArrayAccess/Iterator parameters — parameter types are contravariant, so narrowing is a fatal error at class registration. Confirmed: Declaration of T::offsetExists(int $o) must be compatible with ArrayAccess::offsetExists(mixed $offset).
  • 7 getResource() / Reactor::resource() returns — PHP has no resource type declaration.
  • Future::get() on the interface — its implementations return genuinely different types, so mixed is the correct supertype.

Bug fixes

Both found while verifying the narrowing, and both confirmed pre-existing on trunk by stashing these changes and reproducing on a clean build.

  1. php_scylladb_duration_init — type-confused write. It called PHP_SCYLLADB_GET_DURATION(getThis()) unconditionally, but through Type\Scalar::create() $this is a Type\Scalar, so the macro applied the Duration offset to a Scalar and wrote past the object. Type::duration()->create(1,2,3) returned null.
  2. php_scylladb_timeuuid_init — SIGSEGV. It allocated only when return_value was IS_UNDEF, but the VM pre-initializes it to IS_NULL, so Z_OBJ_P dereferenced a null zval. Type::timeuuid()->create(1) crashed with exit 139.

Both now use the same guard php_scylladb_uuid_init and php_scylladb_bigint_init already use. All 21 scalar types build correctly through create().

Two related corrections:

  • Type\Scalar::create() arity. Arginfo declared 1 parameter while the duration branch parses up to 3 and timestamp up to 2 — a guaranteed Arginfo / zpp mismatch fatal on debug builds. Now variadic, so each branch enforces its own arity.
  • bool|false on three Table interfaces. PHP rejects it as a redundant union. The type is bool.

IDE stubs

tools/gen_ide_stubs.php generates a stub package from the .stub.php sources. It resolves @cvalue UNKNOWN constants against a loaded extension (a plain copy would ship CONSISTENCY_ANY = UNKNOWN), strips build-time annotations, and keeps documentation comments.

It verifies its output against ReflectionExtension and fails if the extension registers a class no stub declares. That check immediately found Cassandra\Custom, registered by hand in src/Custom.c with no stub — hence the new declaration-only src/Custom.stub.php. All classes now match.

The release workflow gains an ide-stubs job that builds the extension once (needed to resolve constants) and attaches ide-stubs-v<version>.tar.gz.

Reviewer notes

  • Cassandra\Float cannot be declared in valid PHP. float is reserved as a class name in every namespace, so only C registration can create it, and Numbers/Float.php fails php -l. PhpStorm, Intelephense, PHPStan and Psalm all parse it, and the package declares no autoloader so it never loads at runtime. This is a permanent wart, not something this PR introduces.
  • *_arginfo.h is build-generated and gitignored (.gitignore:65-66), so nothing regenerated is committed here. CLAUDE.md still says to commit those files — that instruction is stale and worth correcting separately.

Testing

980 passed, 0 failed (13360 assertions) on PHP 8.5 NTS against a live ScyllaDB container. Also verified by reflection that every narrowed signature is what the engine actually registers, and swept all 21 scalar create() paths.

Note the local build is a release build, so the debug-only Arginfo / zpp mismatch check does not fire there — the mismatches were identified by reading zend_execute.c and confirmed by the arity analysis. CI debug builds will exercise it.

php_scylladb_duration_init and php_scylladb_timeuuid_init are reached on two
paths: as the class constructor, and through Type\Scalar::create(). Each helper
only handled the constructor path.

duration_init called PHP_SCYLLADB_GET_DURATION(getThis()). Through create(),
$this is the Type\Scalar object, so the macro applied the Duration offset to a
Scalar and wrote past the object. Type::duration()->create(1, 2, 3) returned
null and corrupted memory.

timeuuid_init allocated the object only when return_value was IS_UNDEF. The VM
sets return_value to IS_NULL before it calls an internal handler, so the
allocation was skipped and Z_OBJ_P read a null zval.
Type::timeuuid()->create(1) crashed with SIGSEGV.

Both helpers now test whether $this already holds the target class, the same
way php_scylladb_uuid_init and php_scylladb_bigint_init do, and allocate into
return_value when it does not. All 21 scalar types now build through create().
Every stub already declared a type, but 71 declarations were mixed. mixed gives
opcache and the JIT nothing to work with, so the call sites keep a type guard
the engine could otherwise drop. This narrows 53 of them.

The value union is Value|string|int|float|bool|null. It comes from
ResultDecoder.c: a decoded Cassandra value is a string, an int, a bool, a float,
null, or one of 19 objects, and all 19 classes implement Value.

Returns now name what the C code produces. FutureRows::get() returns ?Rows,
FutureSession::get() returns ?Session, FuturePreparedStatement::get() returns
?PreparedStatement, and FutureClose::get() returns null. Rows::current(),
Rows::offsetGet() and Rows::first() return ?array, and Rows::key() returns ?int.
The container accessors and the schema option accessors return the value union.

Input parameters carry the union too. This is safe because
php_scylladb_validate_object throws on every value outside the union, and it
accepts a strict subset of it. A value the C code accepts can never fall outside
the declared type, so the engine's arginfo check in debug builds cannot fire.
An array or a foreign object still raises InvalidArgumentException, not
TypeError, so callers see no change.

18 mixed declarations stay, and each one is forced:

  - 10 ArrayAccess and Iterator parameters. Parameter types are contravariant,
    so a narrower type is a fatal error at class registration.
  - 7 getResource() and Reactor::resource() returns. PHP has no resource type
    declaration.
  - Future::get() on the interface. Its implementations return different types,
    so mixed is the correct supertype.

Two related corrections:

Type\Scalar::create() declared one parameter, but the branch for duration parses
up to three and the branch for timestamp parses up to two. A debug build turned
Type::duration()->create(1, 2, 3) into a fatal "Arginfo / zpp mismatch" because
the handler accepted arguments arginfo forbade. The parameter is now variadic,
which leaves each branch to enforce its own arity.

Table::populateIOCacheOnFlush() and Table::replicateOnWrite() declared
bool|false on three interfaces. PHP rejects that union as redundant, so the
generated stub file did not parse. The type is bool, which already covers the
value and the false-when-absent sentinel.
Editors and static analysers cannot read a compiled extension, so users get no
completion for Cassandra\ classes unless they install the extension's headers by
hand. This attaches a generated stub package to every release.

tools/gen_ide_stubs.php builds the package from the .stub.php sources. Those
files are the source of truth for the PHP-visible API, but they cannot ship as
they are:

  - @cvalue constants are written as `= UNKNOWN`. The real values live in
    cassandra.h, so the tool reads them back from a loaded extension. A plain
    copy would show users CONSISTENCY_ANY = UNKNOWN.
  - @generate-class-entries, @strict-properties and @scylladb-struct are
    build-time directives with no meaning for an editor, so the tool strips
    them. Documentation comments stay.

After it writes the files, the tool compares the classes it declared against
ReflectionExtension. A class the extension registers but no stub declares fails
the run. That check found Cassandra\Custom, which src/Custom.c registers by hand
with no stub, so this adds src/Custom.stub.php. The file is declaration-only and
is not passed to php_scylladb_generate_arginfo, because the C file still does
the registration. All 101 classes now match.

The release workflow gains an ide-stubs job. It needs a real build to resolve
the constants, so it compiles the extension once and uploads
ide-stubs-v<version>.tar.gz next to the binaries.

One limit is permanent. PHP reserves float as a class name in every namespace,
so Cassandra\Float cannot be declared in valid PHP and only C registration can
create it. Numbers/Float.php therefore fails php -l. PhpStorm, Intelephense,
PHPStan and Psalm all parse it, and the package declares no autoloader, so the
file never loads at runtime.
@mergify

mergify Bot commented Aug 10, 2026

Copy link
Copy Markdown

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@CodeLieutenant
CodeLieutenant merged commit b16b429 into trunk Aug 10, 2026
33 checks passed
@CodeLieutenant
CodeLieutenant deleted the feat/narrow-stub-types-and-ide-stubs branch August 10, 2026 00:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant