diff --git a/.github/actions/build-extension/action.yml b/.github/actions/build-extension/action.yml index e5b28512b..5ca91b3f8 100644 --- a/.github/actions/build-extension/action.yml +++ b/.github/actions/build-extension/action.yml @@ -21,6 +21,10 @@ inputs: required: false default: 'ON' description: 'Link the C++ driver statically (ON bundles it into the .so)' + sanitize_address: + required: false + default: 'OFF' + description: 'Build with AddressSanitizer (ON enables ENABLE_SANITIZERS + SANITIZE_ADDRESS)' runs: using: composite @@ -43,6 +47,9 @@ runs: -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -DPHP_SCYLLADB_STATIC="${{ inputs.static_driver }}" \ -DPHP_SCYLLADB_BACKEND="${BACKEND}" \ + -DENABLE_SANITIZERS="${{ inputs.sanitize_address }}" \ + -DSANITIZE_ADDRESS="${{ inputs.sanitize_address }}" \ + -DSANITIZE_UNDEFINED=OFF \ -DENABLE_LTO="${{ inputs.enable_lto }}" - name: Build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a301fcd8..6ff684819 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -245,6 +245,106 @@ jobs: SCYLLADB_HOSTS: "127.0.0.1" SCYLLADB_PHP_BACKEND: ${{ steps.driver.outputs.backend }} + test-asan: + name: "ASan: PHP ${{ matrix.php }}-nts / scylladb (memory-safety guard)" + runs-on: ubuntu-24.04 + + services: + scylladb: + image: scylladb/scylla:2026.1 + options: >- + --health-cmd "cqlsh -e 'SELECT cluster_name FROM system.local' 2>/dev/null" + --health-interval 10s + --health-timeout 5s + --health-retries 30 + --health-start-period 60s + ports: + - 9042:9042 + + strategy: + fail-fast: false + matrix: + php: + - "8.4" + - "8.5" + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1 + submodules: recursive + + - name: Install system dependencies + run: | + sudo apt-get update -q + sudo apt-get install -y --no-install-recommends \ + pkg-config build-essential ninja-build libuv1-dev + + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: ${{ matrix.php }} + ini-file: development + coverage: none + tools: composer, php-config, phpize + extensions: pcntl, mbstring, curl, intl + env: + phpts: nts + debug: true + fail-fast: true + + - name: Setup CMake + uses: jwlawson/actions-setup-cmake@0d6a7d60b009d01c9e7523be22153ff8f19460d3 # v2 + with: + cmake-version: "3.30" + + - name: Setup ccache + uses: hendrikmuhs/ccache-action@5ebbd400eff9e74630f759d94ddd7b6c26299639 # v1.2 + with: + key: asan-${{ runner.os }}-${{ matrix.php }}-nts-scylladb + restore-keys: | + asan-${{ runner.os }}-${{ matrix.php }}-nts-scylladb + asan-${{ runner.os }}-${{ matrix.php }}- + max-size: 500M + append-timestamp: false + + - uses: ./.github/actions/install-cpp-driver + id: driver + with: + driver: scylladb + + # AddressSanitizer-instrumented build. This is the regression guard for + # the cycle-collector use-after-free (a shared type's CassDataType freed + # by a mis-accounted get_gc): the whole suite was clean under ASan only + # after the get_gc handlers were fixed. A plain test run is not a reliable + # guard — the fault is heap-layout dependent and need not crash on x86. + - uses: ./.github/actions/build-extension + with: + driver: scylladb + cpp_driver_pkg_config_path: ${{ steps.driver.outputs.pkg_config_path }} + sanitize_address: 'ON' + + - name: Install Composer dependencies + run: | + composer install --no-ansi --no-interaction --no-progress --prefer-dist + + - name: Run tests under AddressSanitizer + # The extension is ASan-instrumented but the setup-php binary is not, so + # the ASan runtime is LD_PRELOADed and USE_ZEND_ALLOC=0 routes Zend + # allocations through it. detect_leaks=0 because PHP intentionally leaks + # persistent allocations at shutdown; detect_odr_violation=0 avoids false + # positives from the statically bundled C++ driver. halt_on_error=1 fails + # the job on the first heap-use-after-free / overflow. + run: | + ASAN_RT="$(gcc -print-file-name=libasan.so)" + echo "ASan runtime: ${ASAN_RT}" + LD_PRELOAD="${ASAN_RT}" \ + USE_ZEND_ALLOC=0 \ + ASAN_OPTIONS="detect_leaks=0:detect_odr_violation=0:verify_asan_link_order=0:abort_on_error=1:halt_on_error=1" \ + php -d extension=cassandra ./vendor/bin/pest --colors=always + env: + SCYLLADB_HOSTS: "127.0.0.1" + SCYLLADB_PHP_BACKEND: ${{ steps.driver.outputs.backend }} + pie-build: name: PIE / PHP ${{ matrix.php }}-nts / ${{ matrix.driver }} runs-on: ubuntu-24.04 diff --git a/cmake/FindLibuv.cmake b/cmake/FindLibuv.cmake index 3de960891..ca3114ccf 100644 --- a/cmake/FindLibuv.cmake +++ b/cmake/FindLibuv.cmake @@ -34,6 +34,29 @@ else () if (LINK_LIBUV_STATIC) pkg_check_modules(LIBUV REQUIRED IMPORTED_TARGET libuv-static) + + # libuv-static.pc lists the archive as "-l:libuv.a" (GNU ld name-form). + # GNU ld resolves it, but Apple's ld64 cannot parse the "-l:name" form + # and fails with: ld: library ':libuv.a' not found. On Apple, resolve + # the archive to an absolute path and substitute it into the imported + # target's link interface so the bare name never reaches the linker. + if (APPLE) + find_library(LIBUV_STATIC_ARCHIVE + NAMES libuv.a uv_a + HINTS ${LIBUV_STATIC_LIBRARY_DIRS} ${LIBUV_LIBRARY_DIRS}) + if (NOT LIBUV_STATIC_ARCHIVE) + message(FATAL_ERROR + "LINK_LIBUV_STATIC=ON but no libuv static archive was found in " + "'${LIBUV_STATIC_LIBRARY_DIRS}'. Install a static libuv " + "or configure with -DLINK_LIBUV_STATIC=OFF.") + endif () + get_target_property(_libuv_link_libs PkgConfig::LIBUV INTERFACE_LINK_LIBRARIES) + list(TRANSFORM _libuv_link_libs + REPLACE "^(-l)?:?libuv\\.a$" "${LIBUV_STATIC_ARCHIVE}") + set_target_properties(PkgConfig::LIBUV PROPERTIES + INTERFACE_LINK_LIBRARIES "${_libuv_link_libs}") + unset(_libuv_link_libs) + endif () else () pkg_check_modules(LIBUV REQUIRED IMPORTED_TARGET libuv) endif () diff --git a/src/Cluster/BuilderHandlers.c b/src/Cluster/BuilderHandlers.c index 0fe64ab4d..2cda1c609 100644 --- a/src/Cluster/BuilderHandlers.c +++ b/src/Cluster/BuilderHandlers.c @@ -9,7 +9,15 @@ extern zend_object_handlers php_scylladb_cluster_builder_handlers; HashTable *php_scylladb_cluster_builder_gc(zend_object *object, zval **table, int *n) { - return zend_std_get_gc(object, table, n); + /* Expose the only zval the builder holds (default_timeout) via the GC + * buffer. Going through zend_std_get_gc would instead invoke the + * side-effecting get_properties (it rebuilds object->properties on every + * call), which corrupts refcounts across the collector's mark/scan passes. */ + auto self = php_scylladb_cluster_builder_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zend_get_gc_buffer_add_zval(buffer, &self->default_timeout); + zend_get_gc_buffer_use(buffer, table, n); + return nullptr; } HashTable *php_scylladb_cluster_builder_properties(zend_object *object) { diff --git a/src/Collection.c b/src/Collection.c index 726d8d9ff..e8c053a2e 100644 --- a/src/Collection.c +++ b/src/Collection.c @@ -288,7 +288,15 @@ PHP_METHOD(Cassandra_Collection, remove) HashTable * php_scylladb_collection_gc(zend_object *object, zval** table, int *n) { - return zend_std_get_gc(object, table, n); + auto self = php_scylladb_collection_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zend_get_gc_buffer_add_zval(buffer, &self->type); + zval *current; + ZEND_HASH_FOREACH_VAL(&self->values, current) { + zend_get_gc_buffer_add_zval(buffer, current); + } ZEND_HASH_FOREACH_END(); + zend_get_gc_buffer_use(buffer, table, n); + return nullptr; } HashTable * diff --git a/src/DefaultSession.c b/src/DefaultSession.c index 3816b6f4c..91fcda877 100644 --- a/src/DefaultSession.c +++ b/src/DefaultSession.c @@ -484,7 +484,7 @@ ZEND_METHOD(Cassandra_DefaultSession, execute) { ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_ZVAL(statement) Z_PARAM_OPTIONAL - Z_PARAM_ZVAL(options) + Z_PARAM_ZVAL_OR_NULL(options) ZEND_PARSE_PARAMETERS_END(); self = PHP_SCYLLADB_GET_SESSION(getThis()); @@ -634,7 +634,7 @@ ZEND_METHOD(Cassandra_DefaultSession, executeAsync) { ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_ZVAL(statement) Z_PARAM_OPTIONAL - Z_PARAM_ZVAL(options) + Z_PARAM_ZVAL_OR_NULL(options) ZEND_PARSE_PARAMETERS_END(); self = PHP_SCYLLADB_GET_SESSION(getThis()); @@ -736,7 +736,7 @@ ZEND_METHOD(Cassandra_DefaultSession, prepare) { ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_ZVAL(cql) Z_PARAM_OPTIONAL - Z_PARAM_ZVAL(options) + Z_PARAM_ZVAL_OR_NULL(options) ZEND_PARSE_PARAMETERS_END(); self = PHP_SCYLLADB_GET_SESSION(getThis()); @@ -843,7 +843,7 @@ ZEND_METHOD(Cassandra_DefaultSession, prepareAsync) { ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_ZVAL(cql) Z_PARAM_OPTIONAL - Z_PARAM_ZVAL(options) + Z_PARAM_ZVAL_OR_NULL(options) ZEND_PARSE_PARAMETERS_END(); self = PHP_SCYLLADB_GET_SESSION(getThis()); diff --git a/src/Map.c b/src/Map.c index c5a5a122a..2b22ff7c3 100644 --- a/src/Map.c +++ b/src/Map.c @@ -429,7 +429,17 @@ PHP_METHOD(Cassandra_Map, offsetExists) HashTable * php_scylladb_map_gc(zend_object *object, zval** table, int *n) { - return zend_std_get_gc(object, table, n); + auto self = php_scylladb_map_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zend_get_gc_buffer_add_zval(buffer, &self->type); + php_scylladb_map_entry *curr, *temp; + HASH_ITER(hh, self->entries, curr, temp) + { + zend_get_gc_buffer_add_zval(buffer, &curr->key); + zend_get_gc_buffer_add_zval(buffer, &curr->value); + } + zend_get_gc_buffer_use(buffer, table, n); + return nullptr; } HashTable * diff --git a/src/Numbers/Bigint.c b/src/Numbers/Bigint.c index bce1b31fc..1752ec9cd 100644 --- a/src/Numbers/Bigint.c +++ b/src/Numbers/Bigint.c @@ -99,7 +99,11 @@ void php_scylladb_bigint_init(INTERNAL_FUNCTION_PARAMETERS) return; } - self->data.bigint.value = (cass_int64_t)Z_DVAL_P(value); + /* zend_dval_to_lval gives PHP's well-defined double->int64 conversion. + * A raw (int64_t) cast is undefined when double_value == 2^63 (which + * (double)INT64_MAX rounds up to, so the bound check above lets it + * through) and saturates differently per platform. */ + self->data.bigint.value = zend_dval_to_lval(double_value); } else if (Z_TYPE_P(value) == IS_STRING) { diff --git a/src/Numbers/Decimal.c b/src/Numbers/Decimal.c index 0d6e9d546..0f8bf2e8d 100644 --- a/src/Numbers/Decimal.c +++ b/src/Numbers/Decimal.c @@ -524,7 +524,13 @@ HashTable *php_scylladb_decimal_gc( #endif zval** table, int *n) { - return zend_std_get_gc(object, table, n); + /* Holds no zvals (mpz_t + scale only). Report no roots directly instead of + * going through zend_std_get_gc, which would invoke the side-effecting + * get_properties (it rebuilds object->properties on every call) and corrupt + * refcounts across the collector's mark/scan passes. */ + *table = nullptr; + *n = 0; + return nullptr; } HashTable *php_scylladb_decimal_properties( diff --git a/src/Numbers/NumberParser.c b/src/Numbers/NumberParser.c index 55838ca05..27dd18a64 100644 --- a/src/Numbers/NumberParser.c +++ b/src/Numbers/NumberParser.c @@ -137,8 +137,11 @@ php_scylladb_parse_int(char* in, int in_len, cass_int32_t* number ) } if (errno == ERANGE) { - zend_throw_exception_ex(php_scylladb_range_exception_ce, 0 , - "value must be between %d and %d, %s given", INT_MIN, INT_MAX, in); + /* Signal the overflow through errno (still ERANGE here) and return without + * throwing. The only callers are Tinyint/Smallint, which emit their own + * narrower "must be between ..." range error; throwing here would both + * report the wrong (32-bit) bounds and clobber errno via the exception + * machinery before the caller can inspect it. */ return 0; } diff --git a/src/Numbers/Smallint.c b/src/Numbers/Smallint.c index 98192b985..76e4a995f 100644 --- a/src/Numbers/Smallint.c +++ b/src/Numbers/Smallint.c @@ -107,10 +107,10 @@ void php_scylladb_smallint_init(INTERNAL_FUNCTION_PARAMETERS) if (!php_scylladb_parse_int(Z_STRVAL_P(value), Z_STRLEN_P(value), &number )) { - // If the parsing function fails, it would have set an exception. If it's - // a range error, the error message would be wrong because the parsing - // function supports all 32-bit values, so the "valid" range it reports would - // be too large for Smallint. Reset the exception in that case. + // parse_int reports a 32-bit overflow via errno == ERANGE without + // throwing (its 32-bit bounds are too wide for Smallint); emit the + // narrower range error here. Any other failure already carries its + // own exception. if (errno == ERANGE) { diff --git a/src/Numbers/Tinyint.c b/src/Numbers/Tinyint.c index 31b5bb9d0..18d050bd9 100644 --- a/src/Numbers/Tinyint.c +++ b/src/Numbers/Tinyint.c @@ -104,10 +104,10 @@ void php_scylladb_tinyint_init(INTERNAL_FUNCTION_PARAMETERS) { if (!php_scylladb_parse_int(Z_STRVAL_P(value), Z_STRLEN_P(value), &number )) { - // If the parsing function fails, it would have set an exception. If it's - // a range error, the error message would be wrong because the parsing - // function supports all 32-bit values, so the "valid" range it reports would - // be too large for Tinyint. Reset the exception in that case. + // parse_int reports a 32-bit overflow via errno == ERANGE without + // throwing (its 32-bit bounds are too wide for Tinyint); emit the + // narrower range error here. Any other failure already carries its + // own exception. if (errno == ERANGE) { diff --git a/src/Numbers/Varint.c b/src/Numbers/Varint.c index 8aef69b65..a960ff514 100644 --- a/src/Numbers/Varint.c +++ b/src/Numbers/Varint.c @@ -386,7 +386,13 @@ HashTable *php_scylladb_varint_gc( #endif zval** table, int *n ) { - return zend_std_get_gc(object, table, n); + /* Holds no zvals (mpz_t only). Report no roots directly instead of going + * through zend_std_get_gc, which would invoke the side-effecting + * get_properties (it rebuilds object->properties on every call) and corrupt + * refcounts across the collector's mark/scan passes. */ + *table = nullptr; + *n = 0; + return nullptr; } HashTable *php_scylladb_varint_properties( diff --git a/src/Set.c b/src/Set.c index dea889b13..32553f839 100644 --- a/src/Set.c +++ b/src/Set.c @@ -280,7 +280,16 @@ PHP_METHOD(Cassandra_Set, rewind) HashTable* php_scylladb_set_gc(zend_object* object, zval** table, int* n) { - return zend_std_get_gc(object, table, n); + auto self = php_scylladb_set_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zend_get_gc_buffer_add_zval(buffer, &self->type); + php_scylladb_set_entry *curr, *temp; + HASH_ITER(hh, self->entries, curr, temp) + { + zend_get_gc_buffer_add_zval(buffer, &curr->value); + } + zend_get_gc_buffer_use(buffer, table, n); + return nullptr; } HashTable* diff --git a/src/Tuple.c b/src/Tuple.c index b41c600f7..00a3cf423 100644 --- a/src/Tuple.c +++ b/src/Tuple.c @@ -259,7 +259,22 @@ ZEND_METHOD(Cassandra_Tuple, rewind) HashTable * php_scylladb_tuple_gc(zend_object *object, zval** table, int *n) { - return zend_std_get_gc(object, table, n); + /* Expose the internal zvals (the tuple type and every element value) to the + * cycle collector. These live in the C struct, not in std properties, so the + * default handler would hide them and the GC could prematurely free a still + * referenced type/value. */ + auto self = php_scylladb_tuple_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + + zend_get_gc_buffer_add_zval(buffer, &self->type); + + zval *current; + ZEND_HASH_FOREACH_VAL(&self->values, current) { + zend_get_gc_buffer_add_zval(buffer, current); + } ZEND_HASH_FOREACH_END(); + + zend_get_gc_buffer_use(buffer, table, n); + return nullptr; } HashTable * diff --git a/src/Type/Collection.c b/src/Type/Collection.c index 58aaaa146..6c89f59b1 100644 --- a/src/Type/Collection.c +++ b/src/Type/Collection.c @@ -107,8 +107,10 @@ HashTable* php_scylladb_type_collection_gc( zendObject* object, #endif zval** table, int* n) { - *table = nullptr; - *n = 0; + auto self = php_scylladb_type_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zend_get_gc_buffer_add_zval(buffer, &self->data.collection.value_type); + zend_get_gc_buffer_use(buffer, table, n); return nullptr; } diff --git a/src/Type/Map.c b/src/Type/Map.c index 89721e1c3..6db506f63 100644 --- a/src/Type/Map.c +++ b/src/Type/Map.c @@ -130,8 +130,11 @@ php_scylladb_type_map_gc( zval** table, int *n ) { - *table = nullptr; - *n = 0; + auto self = php_scylladb_type_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zend_get_gc_buffer_add_zval(buffer, &self->data.map.key_type); + zend_get_gc_buffer_add_zval(buffer, &self->data.map.value_type); + zend_get_gc_buffer_use(buffer, table, n); return nullptr; } diff --git a/src/Type/Set.c b/src/Type/Set.c index d2abf4354..e4c169f93 100644 --- a/src/Type/Set.c +++ b/src/Type/Set.c @@ -109,8 +109,10 @@ php_scylladb_type_set_gc( zval** table, int *n ) { - *table = nullptr; - *n = 0; + auto self = php_scylladb_type_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zend_get_gc_buffer_add_zval(buffer, &self->data.set.value_type); + zend_get_gc_buffer_use(buffer, table, n); return nullptr; } diff --git a/src/Type/Tuple.c b/src/Type/Tuple.c index 1766f8e51..4ee752e5d 100644 --- a/src/Type/Tuple.c +++ b/src/Type/Tuple.c @@ -141,8 +141,17 @@ php_scylladb_type_tuple_gc( zval** table, int* n ) { - *table = nullptr; - *n = 0; + /* Expose the sub-type objects held in the C struct so the cycle collector + * accounts for them instead of treating this type as reference-free. */ + auto self = php_scylladb_type_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + + zval *current; + ZEND_HASH_FOREACH_VAL(&self->data.tuple.types, current) { + zend_get_gc_buffer_add_zval(buffer, current); + } ZEND_HASH_FOREACH_END(); + + zend_get_gc_buffer_use(buffer, table, n); return nullptr; } diff --git a/src/Type/UserType.c b/src/Type/UserType.c index 05ea72509..668b1ed76 100644 --- a/src/Type/UserType.c +++ b/src/Type/UserType.c @@ -232,7 +232,14 @@ php_scylladb_type_user_type_gc( zval** table, int *n ) { - return zend_std_get_gc(object, table, n); + auto self = php_scylladb_type_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zval *current; + ZEND_HASH_FOREACH_VAL(&self->data.udt.types, current) { + zend_get_gc_buffer_add_zval(buffer, current); + } ZEND_HASH_FOREACH_END(); + zend_get_gc_buffer_use(buffer, table, n); + return nullptr; } HashTable * diff --git a/src/UserTypeValue.c b/src/UserTypeValue.c index 2dce14372..31d314752 100644 --- a/src/UserTypeValue.c +++ b/src/UserTypeValue.c @@ -281,7 +281,15 @@ ZEND_METHOD(Cassandra_UserTypeValue, rewind) HashTable * php_scylladb_user_type_value_gc(zend_object *object, zval** table, int *n) { - return zend_std_get_gc(object, table, n); + auto self = php_scylladb_user_type_value_object_fetch(object); + zend_get_gc_buffer *buffer = zend_get_gc_buffer_create(); + zend_get_gc_buffer_add_zval(buffer, &self->type); + zval *current; + ZEND_HASH_FOREACH_VAL(&self->values, current) { + zend_get_gc_buffer_add_zval(buffer, current); + } ZEND_HASH_FOREACH_END(); + zend_get_gc_buffer_use(buffer, table, n); + return nullptr; } HashTable * diff --git a/tests/Feature/Sessions/NullOptionsTest.php b/tests/Feature/Sessions/NullOptionsTest.php new file mode 100644 index 000000000..42aab0c1e --- /dev/null +++ b/tests/Feature/Sessions/NullOptionsTest.php @@ -0,0 +1,92 @@ + dropKeyspace($keyspace)); + +it('execute() accepts an explicit null $options', function () use ($keyspace) { + $session = scyllaDbConnection($keyspace); + + $rows = $session->execute(new SimpleStatement('SELECT id FROM items'), null); + + expect($rows)->toBeInstanceOf(Rows::class); + expect($rows->count())->toBe(0); +})->group('feature', 'sessions'); + +it('executeAsync() accepts an explicit null $options', function () use ($keyspace) { + $session = scyllaDbConnection($keyspace); + + $future = $session->executeAsync(new SimpleStatement('SELECT id FROM items'), null); + + expect($future)->toBeInstanceOf(FutureRows::class); + expect($future->get()->count())->toBe(0); +})->group('feature', 'sessions'); + +it('prepare() accepts an explicit null $options', function () use ($keyspace) { + $session = scyllaDbConnection($keyspace); + + $prepared = $session->prepare('SELECT id FROM items WHERE id = ?', null); + + expect($prepared)->toBeInstanceOf(PreparedStatement::class); +})->group('feature', 'sessions'); + +it('prepareAsync() accepts an explicit null $options', function () use ($keyspace) { + $session = scyllaDbConnection($keyspace); + + $future = $session->prepareAsync('SELECT id FROM items WHERE id = ?', null); + + expect($future)->toBeInstanceOf(FuturePreparedStatement::class); + expect($future->get())->toBeInstanceOf(PreparedStatement::class); +})->group('feature', 'sessions'); + +it('explicit null $options behaves the same as omitting it', function () use ($keyspace) { + $session = scyllaDbConnection($keyspace); + + $omitted = $session->execute(new SimpleStatement('SELECT id FROM items')); + $explicit = $session->execute(new SimpleStatement('SELECT id FROM items'), null); + + expect($omitted->count())->toBe($explicit->count()); +})->group('feature', 'sessions'); + +it('still accepts an array $options after the null fix', function () use ($keyspace) { + $session = scyllaDbConnection($keyspace); + + $rows = $session->execute( + new SimpleStatement('SELECT id FROM items'), + ['page_size' => 10], + ); + + expect($rows)->toBeInstanceOf(Rows::class); +})->group('feature', 'sessions'); diff --git a/tests/Unit/GarbageCollectionTest.php b/tests/Unit/GarbageCollectionTest.php new file mode 100644 index 000000000..67da77bfc --- /dev/null +++ b/tests/Unit/GarbageCollectionTest.php @@ -0,0 +1,89 @@ +properties on every call; because the + * cycle collector calls get_gc several times per run, it decremented one set of + * child refcounts and re-incremented a freshly rebuilt set, corrupting + * refcounts and eventually freeing a shared type's CassDataType (a crash on + * teardown). The fix gives each handler a real zend_get_gc_buffer over its + * actual C-struct zvals. + * + * NOTE ON SCOPE: this test exercises every fixed get_gc path through + * gc_collect_cycles() and asserts the objects stay correct, but it is NOT a + * standalone reproducer — the original fault only surfaced after the cumulative + * allocation churn of the full suite. The definitive regression guard is to run + * the whole test suite under AddressSanitizer (USE_ZEND_ALLOC=0 + the ASan + * runtime): it was red before the fix and clean after. Keep that in CI. + */ + +it('runs every fixed collection/type get_gc path through the collector', function () { + $intType = Type::int(); + $setType = Type::set($intType); + $tupleType = Type::tuple($intType, Type::varchar(), $setType); + + for ($i = 0; $i < 200; $i++) { + $values = [ + $setType->create(1, 2, 3), + Type::map(Type::varchar(), $intType)->create('a', 1, 'b', 2), + Type::collection($intType)->create(4, 5, 6), + $tupleType->create(9, 'x', $setType->create(7, 8)), + ]; + + foreach ($values as $v) { + // Materialise object->properties — what the old get_gc rebuilt. + $props = (array) $v; + $dump = print_r($v, true); + unset($props, $dump); + } + + // Trap the values and the shared types in a reference cycle, then drop + // all strong refs so only the collector can reclaim them. + $cycle = new stdClass(); + $cycle->self = $cycle; + $cycle->values = $values; + $cycle->types = [$intType, $setType, $tupleType]; + unset($values, $cycle); + + gc_collect_cycles(); + } + + // The shared types survive the churn and still produce correct values. + expect($setType->create(1)->values())->toBe([1]) + ->and($tupleType->create(1, 'y', $setType->create(2))->get(0))->toBe(1); +})->group('unit', 'gc'); + +it('runs Decimal/Varint and cluster-builder get_gc paths through the collector', function () { + for ($i = 0; $i < 200; $i++) { + $objs = [ + new Decimal('3.14159265358979323846'), + new Varint('123456789012345678901234567890'), + Cassandra::cluster()->withDefaultTimeout(12.5)->withContactPoints('127.0.0.1'), + ]; + + foreach ($objs as $o) { + $props = (array) $o; + unset($props); + } + + $cycle = new stdClass(); + $cycle->self = $cycle; + $cycle->objs = $objs; + unset($objs, $cycle); + + gc_collect_cycles(); + } + + expect((string) new Decimal('42'))->toBe('42') + ->and((string) new Varint('42'))->toBe('42'); +})->group('unit', 'gc');