diff --git a/.github/workflows/build-cross-platform.yml b/.github/workflows/build-cross-platform.yml index 5aa33ba..ce7efb6 100644 --- a/.github/workflows/build-cross-platform.yml +++ b/.github/workflows/build-cross-platform.yml @@ -7,21 +7,35 @@ on: branches: [build] jobs: - build: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - include: - - os: ubuntu-latest - platform: linux - - os: windows-latest - platform: windows - - os: macos-latest - platform: macos - - runs-on: ${{ matrix.os }} + # Ubuntu build with database services (matches build.yml) + ubuntu-database-build: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.3 + env: + MYSQL_ROOT_PASSWORD: '' + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: test + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test + ports: ['5432:5432'] + options: >- + --health-cmd="pg_isready" + --health-interval=10s + --health-timeout=5s + --health-retries=5 steps: - name: Checkout php-async repo @@ -38,83 +52,52 @@ jobs: mkdir -p php-src/ext/async cp -r async/* php-src/ext/async/ - # ==================== UBUNTU DEPENDENCIES ==================== - - name: Install build dependencies (Ubuntu) - if: matrix.os == 'ubuntu-latest' + - name: Install build dependencies run: | sudo apt-get update sudo apt-get install -y \ gcc g++ autoconf bison re2c \ - libgmp-dev libicu-dev libtidy-dev libenchant-2-dev \ - libzip-dev libbz2-dev libsqlite3-dev libwebp-dev libonig-dev libcurl4-openssl-dev \ - libxml2-dev libxslt1-dev libreadline-dev libsodium-dev \ - libargon2-dev libjpeg-dev libpng-dev libfreetype6-dev libuv1-dev - g++ --version - sudo mkdir -p /var/lib/snmp && sudo chown $(id -u):$(id -g) /var/lib/snmp - - # ==================== WINDOWS DEPENDENCIES ==================== - - name: Install build dependencies (Windows) - if: matrix.os == 'windows-latest' - run: | - # Install php-sdk - git clone https://github.com/Microsoft/php-sdk-binary-tools.git C:\php-sdk - - # Install vcpkg and LibUV - git clone https://github.com/Microsoft/vcpkg.git C:\vcpkg - C:\vcpkg\bootstrap-vcpkg.bat - C:\vcpkg\vcpkg.exe install libuv:x64-windows - - # Create deps structure for php-sdk - mkdir C:\php-sdk\deps\include\libuv - mkdir C:\php-sdk\deps\lib - - # Copy LibUV files - xcopy /E /I C:\vcpkg\installed\x64-windows\include C:\php-sdk\deps\include\libuv\ - copy C:\vcpkg\installed\x64-windows\lib\uv.lib C:\php-sdk\deps\lib\libuv.lib - shell: cmd + libgmp-dev libicu-dev libtidy-dev libsasl2-dev \ + libzip-dev libbz2-dev libsqlite3-dev libonig-dev libcurl4-openssl-dev \ + libxml2-dev libxslt1-dev libpq-dev libreadline-dev libldap2-dev libsodium-dev \ + libargon2-dev \ + firebird-dev \ + valgrind cmake - # ==================== MACOS DEPENDENCIES ==================== - - name: Install build dependencies (macOS) - if: matrix.os == 'macos-latest' + - name: Install LibUV >= 1.44.0 run: | - # Core build tools - brew install autoconf automake libtool pkg-config bison - - # LibUV - main dependency - brew install libuv - - # Fixed package names - brew install tidy-html5 icu4c openssl@3 - - # Additional dependencies - brew install gmp libzip bzip2 sqlite oniguruma curl - brew install libxml2 libxslt readline libsodium argon2 - - # Setup environment variables for keg-only packages - echo "PKG_CONFIG_PATH=$(brew --prefix openssl@3)/lib/pkgconfig:$(brew --prefix icu4c)/lib/pkgconfig:$(brew --prefix libxml2)/lib/pkgconfig:$PKG_CONFIG_PATH" >> $GITHUB_ENV - echo "PATH=$(brew --prefix bison)/bin:$PATH" >> $GITHUB_ENV + # Check if system libuv meets requirements + if pkg-config --exists libuv && pkg-config --atleast-version=1.44.0 libuv; then + echo "System libuv version: $(pkg-config --modversion libuv)" + sudo apt-get install -y libuv1-dev + else + echo "Installing LibUV 1.44.0 from source" + wget https://github.com/libuv/libuv/archive/v1.44.0.tar.gz + tar -xzf v1.44.0.tar.gz + cd libuv-1.44.0 + mkdir build && cd build + cmake .. -DCMAKE_BUILD_TYPE=Release + make -j$(nproc) + sudo make install + sudo ldconfig + cd ../.. + fi - # ==================== UBUNTU CONFIGURE & BUILD ==================== - - name: Configure PHP (Ubuntu) - if: matrix.os == 'ubuntu-latest' + - name: Configure PHP working-directory: php-src run: | ./buildconf -f ./configure \ --enable-zts \ - --enable-option-checking=fatal \ - --prefix=/usr \ - --disable-phpdbg \ --enable-fpm \ --enable-opcache \ + --with-pdo-mysql=mysqlnd \ + --with-mysqli=mysqlnd \ + --with-pgsql \ + --with-pdo-pgsql \ --with-pdo-sqlite \ --enable-intl \ --without-pear \ - --enable-gd \ - --with-jpeg \ - --with-webp \ - --with-freetype \ - --enable-exif \ --with-zip \ --with-zlib \ --enable-soap \ @@ -136,11 +119,12 @@ jobs: --enable-bcmath \ --enable-calendar \ --enable-ftp \ - --with-enchant=/usr \ --enable-sysvmsg \ --with-ffi \ --enable-zend-test \ --enable-dl-test=shared \ + --with-ldap \ + --with-ldap-sasl \ --with-password-argon2 \ --with-mhash \ --with-sodium \ @@ -150,10 +134,10 @@ jobs: --enable-inifile \ --with-config-file-path=/etc \ --with-config-file-scan-dir=/etc/php.d \ + --with-pdo-firebird \ --enable-async - - name: Build PHP (Ubuntu) - if: matrix.os == 'ubuntu-latest' + - name: Build PHP working-directory: php-src run: | make -j"$(nproc)" @@ -165,6 +149,106 @@ jobs: echo "opcache.protect_memory=1" } > /etc/php.d/opcache.ini + - name: Run tests + working-directory: php-src/ext/async + run: | + /usr/local/bin/php -v + /usr/local/bin/php ../../run-tests.php \ + -d zend_extension=opcache.so \ + -d opcache.enable_cli=1 \ + -d opcache.jit_buffer_size=64M \ + -d opcache.jit=tracing \ + -d zend_test.observer.enabled=1 \ + -d zend_test.observer.show_output=0 \ + -P -q -x -j4 \ + -g FAIL,BORK,LEAK,XLEAK \ + --no-progress \ + --offline \ + --show-diff \ + --show-slow 2000 \ + --set-timeout 120 \ + --repeat 2 + + # Cross-platform build without database services + cross-platform-build: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + include: + - os: windows-latest + platform: windows + - os: macos-latest + platform: macos + + runs-on: ${{ matrix.os }} + + + steps: + - name: Checkout php-async repo + uses: actions/checkout@v4 + with: + path: async + + - name: Clone php-src (true-async-stable) + run: | + git clone --depth=1 --branch=true-async-stable https://github.com/true-async/php-src php-src + + - name: Copy php-async extension into php-src + run: | + mkdir -p php-src/ext/async + cp -r async/* php-src/ext/async/ + + # ==================== WINDOWS DEPENDENCIES ==================== + - name: Install build dependencies (Windows) + if: matrix.os == 'windows-latest' + run: | + # Install php-sdk + git clone https://github.com/Microsoft/php-sdk-binary-tools.git C:\php-sdk + + # Install vcpkg and LibUV >= 1.44.0 + git clone https://github.com/Microsoft/vcpkg.git C:\vcpkg + C:\vcpkg\bootstrap-vcpkg.bat + C:\vcpkg\vcpkg.exe install libuv:x64-windows + + # Verify LibUV version + C:\vcpkg\vcpkg.exe list libuv + + # Create deps structure for php-sdk + mkdir C:\php-sdk\deps\include\libuv + mkdir C:\php-sdk\deps\lib + + # Copy LibUV files + xcopy /E /I C:\vcpkg\installed\x64-windows\include C:\php-sdk\deps\include\libuv\ + copy C:\vcpkg\installed\x64-windows\lib\uv.lib C:\php-sdk\deps\lib\libuv.lib + shell: cmd + + # ==================== MACOS DEPENDENCIES ==================== + - name: Install build dependencies (macOS) + if: matrix.os == 'macos-latest' + run: | + # Core build tools + brew install autoconf automake libtool pkg-config bison + + # LibUV >= 1.44.0 - main dependency + brew install libuv + + # Verify LibUV version + pkg-config --modversion libuv || true + libuv_version=$(pkg-config --modversion libuv 2>/dev/null || echo "unknown") + echo "Installed LibUV version: $libuv_version" + + # Fixed package names + brew install tidy-html5 icu4c openssl@3 + + # Additional dependencies + brew install gmp libzip bzip2 sqlite oniguruma curl + brew install libxml2 libxslt readline libsodium argon2 + + # Setup environment variables for keg-only packages + echo "PKG_CONFIG_PATH=$(brew --prefix openssl@3)/lib/pkgconfig:$(brew --prefix icu4c)/lib/pkgconfig:$(brew --prefix libxml2)/lib/pkgconfig:$PKG_CONFIG_PATH" >> $GITHUB_ENV + echo "PATH=$(brew --prefix bison)/bin:$PATH" >> $GITHUB_ENV + # ==================== WINDOWS CONFIGURE & BUILD ==================== - name: Configure and Build PHP (Windows) if: matrix.os == 'windows-latest' @@ -241,70 +325,45 @@ jobs: } > /usr/local/etc/php.d/opcache.ini # ==================== TESTING FOR ALL PLATFORMS ==================== - - name: Run tests (Ubuntu) - if: matrix.os == 'ubuntu-latest' - working-directory: php-src - env: - MIBS: +ALL - run: | - sapi/cli/php run-tests.php \ - -d zend_extension=opcache.so \ - -d opcache.enable_cli=1 \ - -d opcache.jit_buffer_size=64M \ - -d opcache.jit=tracing \ - -d zend_test.observer.enabled=1 \ - -d zend_test.observer.show_output=0 \ - -P -q -x -j2 \ - -g FAIL,BORK,LEAK,XLEAK \ - --no-progress \ - --offline \ - --show-diff \ - --show-slow 1000 \ - --set-timeout 120 \ - --repeat 2 \ - ext/async - - name: Run tests (Windows) if: matrix.os == 'windows-latest' - working-directory: php-src + working-directory: php-src/ext/async run: | php.exe -v - php.exe run-tests.php ^ + php.exe ../../run-tests.php ^ -d zend_extension=opcache.dll ^ -d opcache.enable_cli=1 ^ -d opcache.jit_buffer_size=64M ^ -d opcache.jit=tracing ^ -d zend_test.observer.enabled=1 ^ -d zend_test.observer.show_output=0 ^ - -P -q -x -j2 ^ + -P -q -x -j4 ^ -g FAIL,BORK,LEAK,XLEAK ^ --no-progress ^ --offline ^ --show-diff ^ - --show-slow 1000 ^ + --show-slow 2000 ^ --set-timeout 120 ^ - --repeat 2 ^ - ext/async + --repeat 2 shell: cmd - name: Run tests (macOS) if: matrix.os == 'macos-latest' - working-directory: php-src + working-directory: php-src/ext/async run: | /usr/local/bin/php -v - /usr/local/bin/php run-tests.php \ + /usr/local/bin/php ../../run-tests.php \ -d zend_extension=opcache.so \ -d opcache.enable_cli=1 \ -d opcache.jit_buffer_size=64M \ -d opcache.jit=tracing \ -d zend_test.observer.enabled=1 \ -d zend_test.observer.show_output=0 \ - -P -q -x -j2 \ + -P -q -x -j4 \ -g FAIL,BORK,LEAK,XLEAK \ --no-progress \ --offline \ --show-diff \ - --show-slow 1000 \ + --show-slow 2000 \ --set-timeout 120 \ - --repeat 2 \ - ext/async \ No newline at end of file + --repeat 2 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 55879d8..e9edced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,13 +18,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - GC support for finally handlers, exception handlers, and function call parameters - GC tracking for waker events, internal context, and nested async structures - Prevents memory leaks in complex async applications with circular references +- **Key Order Preservation**: Added `preserveKeyOrder` parameter to async await functions + - Added `preserve_key_order` parameter to `async_await_futures()` API function + - Added `preserve_key_order` field to `async_await_context_t` structure + - Enhanced `awaitAll()`, `awaitAllWithErrors()`, `awaitAnyOf()`, and `awaitAnyOfWithErrors()` functions with `preserveKeyOrder` parameter (defaults to `true`) + - Allows controlling whether the original key order is maintained in result arrays ### Fixed - Memory management improvements for long-running async applications - Proper cleanup of coroutine and scope objects during garbage collection cycles +- **Async Iterator API**: + - Fixed iterator state management to prevent memory leaks +- Fixed the `spawnWith()` function for interaction with the `ScopeProvider` and `SpawnStrategy` interface ### Changed - **LibUV requirement increased to ≥ 1.44.0** - Requires libuv version 1.44.0 or later to ensure proper UV_RUN_ONCE behavior and prevent busy loop issues that could cause high CPU usage +- **Async Iterator API**: + - Proper handling of `REWIND`/`NEXT` states in a concurrent environment. + The iterator code now stops iteration in + coroutines if the iterator is in the process of changing its position. + - Added functionality for proper handling of exceptions from `Zend iterators` (`\Iterator` and `generators`). + An exception that occurs in the iterator can now be handled by the iterator’s owner. ## [0.2.0] - 2025-07-01 diff --git a/async.c b/async.c index 4675613..829fdf2 100644 --- a/async.c +++ b/async.c @@ -125,7 +125,7 @@ PHP_FUNCTION(Async_spawnWith) coroutine->coroutine.fcall = fcall; - RETURN_OBJ(&coroutine->std); + RETURN_OBJ_COPY(&coroutine->std); } PHP_FUNCTION(Async_suspend) @@ -157,16 +157,21 @@ PHP_FUNCTION(Async_protect) ZEND_COROUTINE_SET_PROTECTED(coroutine); } - zval result; - ZVAL_UNDEF(&result); + ZVAL_UNDEF(return_value); zval closure_zval; ZVAL_OBJ(&closure_zval, closure); - if (UNEXPECTED(call_user_function(NULL, NULL, &closure_zval, &result, 0, NULL) == FAILURE)) { + if (UNEXPECTED(call_user_function(NULL, NULL, &closure_zval, return_value, 0, NULL) == FAILURE)) { zend_throw_error(NULL, "Failed to call finally handler in finished coroutine"); - zval_ptr_dtor(&result); + zval_ptr_dtor(return_value); + } + + if (Z_TYPE_P(return_value) == IS_UNDEF) { + // If the closure did not return a value, we return NULL. + ZVAL_NULL(return_value); } + } zend_catch { do_bailout = true; } zend_end_try(); @@ -179,6 +184,10 @@ PHP_FUNCTION(Async_protect) zend_bailout(); } + if (UNEXPECTED(coroutine == NULL)) { + return; + } + async_coroutine_t *async_coroutine = (async_coroutine_t *) coroutine; if (async_coroutine->deferred_cancellation) { @@ -308,6 +317,7 @@ PHP_FUNCTION(Async_awaitAny) results, NULL, false, + false, false ); @@ -357,6 +367,7 @@ PHP_FUNCTION(Async_awaitFirstSuccess) results, errors, false, + false, true ); @@ -390,11 +401,13 @@ PHP_FUNCTION(Async_awaitAll) { zval * futures; zend_object * cancellation = NULL; + bool preserve_key_order = true; - ZEND_PARSE_PARAMETERS_START(1, 2) + ZEND_PARSE_PARAMETERS_START(1, 3) Z_PARAM_ZVAL(futures); Z_PARAM_OPTIONAL Z_PARAM_OBJ_OF_CLASS_OR_NULL(cancellation, async_ce_awaitable); + Z_PARAM_BOOL(preserve_key_order); ZEND_PARSE_PARAMETERS_END(); SCHEDULER_LAUNCH; @@ -409,8 +422,11 @@ PHP_FUNCTION(Async_awaitAll) 0, results, NULL, - false, - false + // For awaitAll, it’s always necessary to fill the result with NULL, + // because the order of keys matters. + true, + preserve_key_order, + true ); if (EG(exception)) { @@ -425,11 +441,15 @@ PHP_FUNCTION(Async_awaitAllWithErrors) { zval * futures; zend_object * cancellation = NULL; + bool preserve_key_order = true; + bool fill_null = false; - ZEND_PARSE_PARAMETERS_START(1, 2) + ZEND_PARSE_PARAMETERS_START(1, 4) Z_PARAM_ZVAL(futures); Z_PARAM_OPTIONAL Z_PARAM_OBJ_OF_CLASS_OR_NULL(cancellation, async_ce_awaitable); + Z_PARAM_BOOL(preserve_key_order); + Z_PARAM_BOOL(fill_null); ZEND_PARSE_PARAMETERS_END(); SCHEDULER_LAUNCH; @@ -445,7 +465,8 @@ PHP_FUNCTION(Async_awaitAllWithErrors) 0, results, errors, - false, + fill_null, + preserve_key_order, true ); @@ -472,12 +493,14 @@ PHP_FUNCTION(Async_awaitAnyOf) zval * futures; zend_object * cancellation = NULL; zend_long count = 0; + bool preserve_key_order = true; - ZEND_PARSE_PARAMETERS_START(2, 3) + ZEND_PARSE_PARAMETERS_START(2, 4) Z_PARAM_LONG(count) Z_PARAM_ITERABLE(futures); Z_PARAM_OPTIONAL Z_PARAM_OBJ_OF_CLASS_OR_NULL(cancellation, async_ce_awaitable); + Z_PARAM_BOOL(preserve_key_order); ZEND_PARSE_PARAMETERS_END(); SCHEDULER_LAUNCH; @@ -497,6 +520,7 @@ PHP_FUNCTION(Async_awaitAnyOf) results, NULL, false, + preserve_key_order, false ); @@ -513,12 +537,16 @@ PHP_FUNCTION(Async_awaitAnyOfWithErrors) zval * futures; zend_object * cancellation = NULL; zend_long count = 0; + bool preserve_key_order = true; + bool fill_null = false; - ZEND_PARSE_PARAMETERS_START(2, 3) + ZEND_PARSE_PARAMETERS_START(2, 5) Z_PARAM_LONG(count) Z_PARAM_ZVAL(futures); Z_PARAM_OPTIONAL Z_PARAM_OBJ_OF_CLASS_OR_NULL(cancellation, async_ce_awaitable); + Z_PARAM_BOOL(preserve_key_order); + Z_PARAM_BOOL(fill_null); ZEND_PARSE_PARAMETERS_END(); HashTable * results = zend_new_array(8); @@ -534,7 +562,8 @@ PHP_FUNCTION(Async_awaitAnyOfWithErrors) 0, results, errors, - false, + fill_null, + preserve_key_order, true ); @@ -629,7 +658,7 @@ PHP_FUNCTION(Async_currentContext) async_context_t *context = async_context_new(); context->scope = scope; scope->context = &context->base; - RETURN_OBJ(&context->std); + RETURN_OBJ_COPY(&context->std); } // Return the existing context from scope diff --git a/async.stub.php b/async.stub.php index 87e1094..554fea5 100644 --- a/async.stub.php +++ b/async.stub.php @@ -33,7 +33,7 @@ function suspend(): void {} /** * Execute the provided closure in non-cancellable mode. */ -function protect(\Closure $closure): void {} +function protect(\Closure $closure): mixed {} function await(Awaitable $awaitable, ?Awaitable $cancellation = null): mixed {} @@ -41,13 +41,13 @@ function awaitAny(iterable $triggers, ?Awaitable $cancellation = null): mixed {} function awaitFirstSuccess(iterable $triggers, ?Awaitable $cancellation = null): mixed {} -function awaitAll(iterable $triggers, ?Awaitable $cancellation = null, bool $fillNull = false): array {} +function awaitAll(iterable $triggers, ?Awaitable $cancellation = null, bool $preserveKeyOrder = true): array {} -function awaitAllWithErrors(iterable $triggers, ?Awaitable $cancellation = null, bool $fillNull = false): array {} +function awaitAllWithErrors(iterable $triggers, ?Awaitable $cancellation = null, bool $preserveKeyOrder = true, bool $fillNull = false): array {} -function awaitAnyOf(int $count, iterable $triggers, ?Awaitable $cancellation = null): array {} +function awaitAnyOf(int $count, iterable $triggers, ?Awaitable $cancellation = null, bool $preserveKeyOrder = true): array {} -function awaitAnyOfWithErrors(int $count, iterable $triggers, ?Awaitable $cancellation = null): array {} +function awaitAnyOfWithErrors(int $count, iterable $triggers, ?Awaitable $cancellation = null, bool $preserveKeyOrder= true, bool $fillNull = false): array {} function delay(int $ms): void {} diff --git a/async_API.c b/async_API.c index 90b1164..1053edf 100644 --- a/async_API.c +++ b/async_API.c @@ -39,6 +39,14 @@ zend_async_scope_t * async_provide_scope(zend_object *scope_provider) zval_ptr_dtor(&retval); + if (UNEXPECTED(EG(exception))) { + return NULL; + } + + if (Z_TYPE(retval) == IS_NULL) { + return NULL; + } + zend_async_throw( ZEND_ASYNC_EXCEPTION_DEFAULT, "Scope provider must return an instance of Async\\Scope" @@ -111,8 +119,11 @@ zend_coroutine_t *spawn(zend_async_scope_t *scope, zend_object * scope_provider, return NULL; } + const bool is_spawn_strategy = scope_provider != NULL + && instanceof_function(scope_provider->ce, async_ce_spawn_strategy); + // call SpawnStrategy::beforeCoroutineEnqueue - if (scope_provider != NULL) { + if (is_spawn_strategy) { zval coroutine_zval, scope_zval; ZVAL_OBJ(&coroutine_zval, &coroutine->std); ZVAL_OBJ(&scope_zval, scope->scope_object); @@ -164,7 +175,7 @@ zend_coroutine_t *spawn(zend_async_scope_t *scope, zend_object * scope_provider, } // call SpawnStrategy::afterCoroutineEnqueue - if (scope_provider != NULL) { + if (is_spawn_strategy) { zval coroutine_zval, scope_zval; ZVAL_OBJ(&coroutine_zval, &coroutine->std); ZVAL_OBJ(&scope_zval, scope->scope_object); @@ -263,9 +274,11 @@ static zend_class_entry* async_get_class_ce(zend_async_class type) //////////////////////////////////////////////////////////////////// #define AWAIT_ALL(await_context) ((await_context)->waiting_count == 0 || (await_context)->waiting_count == (await_context)->total) -#define ITERATOR_IS_FINISHED(await_context) \ - ((await_context->ignore_errors ? await_context->success_count : await_context->resolved_count) >= await_context->waiting_count || \ - (await_context->total != 0 && await_context->resolved_count >= await_context->total) \ +#define AWAIT_ITERATOR_IS_FINISHED(await_context) \ + ((await_context->waiting_count > 0 \ + && (await_context->ignore_errors ? await_context->success_count : await_context->resolved_count) \ + >= await_context->waiting_count) || \ + (await_context->total != 0 && await_context->resolved_count >= await_context->total) \ ) static zend_always_inline zend_async_event_t * zval_to_event(const zval * current) @@ -287,21 +300,43 @@ static zend_always_inline zend_async_event_t * zval_to_event(const zval * curren } } -void async_waiting_callback_dispose(zend_async_event_callback_t *callback, zend_async_event_t * event) +/** + * The function is called to release resources for the callback structure. + * + * @param callback + * @param event + */ +static void async_waiting_callback_dispose(zend_async_event_callback_t *callback, zend_async_event_t * event) { async_await_callback_t * await_callback = (async_await_callback_t *) callback; async_await_context_t * await_context = await_callback->await_context; await_callback->await_context = NULL; + zval_ptr_dtor(&await_callback->key); + if (await_context != NULL) { await_context->dtor(await_context); } - await_callback->prev_dispose(callback, event); + if (await_callback->prev_dispose != NULL) { + await_callback->prev_dispose(callback, event); + } else { + efree(callback); + } } -void async_waiting_callback( +/** + * This callback is used for awaiting futures. + * It is called when the future is resolved or rejected. + * It updates the await context and resumes the coroutine if necessary. + * + * @param event + * @param callback + * @param result + * @param exception + */ +static void async_waiting_callback( zend_async_event_t *event, zend_async_event_callback_t *callback, void *result, @@ -388,11 +423,11 @@ void async_waiting_callback( } } - if (UNEXPECTED(ITERATOR_IS_FINISHED(await_context))) { + if (UNEXPECTED(AWAIT_ITERATOR_IS_FINISHED(await_context) && await_callback->callback.coroutine != NULL)) { ZEND_ASYNC_RESUME(await_callback->callback.coroutine); } - callback->dispose(callback, NULL); + ZEND_ASYNC_EVENT_CALLBACK_RELEASE(callback); } /** @@ -405,7 +440,7 @@ void async_waiting_callback( * @param result * @param exception */ -void async_waiting_cancellation_callback( +static void async_waiting_cancellation_callback( zend_async_event_t *event, zend_async_event_callback_t *callback, void *result, @@ -451,7 +486,15 @@ void async_waiting_cancellation_callback( callback->dispose(callback, NULL); } -zend_result await_iterator_handler(async_iterator_t *iterator, zval *current, zval *key) +/** + * A function that is called to process a single iteration element. + * + * @param iterator + * @param current + * @param key + * @return + */ +static zend_result await_iterator_handler(async_iterator_t *iterator, zval *current, zval *key) { async_await_iterator_t * await_iterator = ((async_await_iterator_iterator_t *) iterator)->await_iterator; @@ -465,13 +508,21 @@ zend_result await_iterator_handler(async_iterator_t *iterator, zval *current, zv return FAILURE; } - if (awaitable == NULL || ZEND_ASYNC_EVENT_IS_CLOSED(awaitable)) { + if (awaitable == NULL || zend_async_waker_is_event_exists(await_iterator->waiting_coroutine, awaitable)) { return SUCCESS; } + if (Z_TYPE_P(key) != IS_STRING && Z_TYPE_P(key) != IS_LONG + && Z_TYPE_P(key) != IS_NULL && Z_TYPE_P(key) != IS_UNDEF) { + async_throw_error("Invalid key type: must be string, long or null"); + return FAILURE; + } + async_await_callback_t * callback = ecalloc(1, sizeof(async_await_callback_t)); callback->callback.base.callback = async_waiting_callback; - callback->await_context = await_iterator->await_context; + async_await_context_t * await_context = await_iterator->await_context; + callback->await_context = await_context; + await_context->ref_count++; ZVAL_COPY(&callback->key, key); @@ -490,26 +541,122 @@ zend_result await_iterator_handler(async_iterator_t *iterator, zval *current, zv } // Add the empty element to the results array if all elements are awaited - if (await_iterator->await_context->results != NULL && AWAIT_ALL(await_iterator->await_context)) { + if (await_context->results != NULL && await_context->fill_missing_with_null) { if (Z_TYPE(callback->key) == IS_STRING) { - zend_hash_add_empty_element(await_iterator->await_context->results, Z_STR_P(key)); + zend_hash_add_empty_element(await_context->results, Z_STR_P(key)); } else if (Z_TYPE(callback->key) == IS_LONG) { - zend_hash_index_add_empty_element(await_iterator->await_context->results, Z_LVAL_P(key)); + zend_hash_index_add_empty_element(await_context->results, Z_LVAL_P(key)); } - } else if (await_iterator->await_context->results != NULL && await_iterator->await_context->fill_missing_with_null) { + } else if (await_context->results != NULL && await_context->preserve_key_order) { + zval undef_val; + // The PRT NULL type is used to fill the array with empty elements that will later be removed. + ZVAL_PTR(&undef_val, NULL); if (Z_TYPE(callback->key) == IS_STRING) { - zend_hash_add(await_iterator->await_context->results, Z_STR_P(key), &EG(uninitialized_zval)); + zend_hash_add(await_context->results, Z_STR_P(key), &undef_val); } else if (Z_TYPE(callback->key) == IS_LONG) { - zend_hash_index_add(await_iterator->await_context->results, Z_LVAL_P(key), &EG(uninitialized_zval)); + zend_hash_index_add(await_context->results, Z_LVAL_P(key), &undef_val); } } + if (ZEND_ASYNC_EVENT_IS_CLOSED(awaitable)) { + // + // The event is already closed. + // But if it supports the replay method, we can retrieve the resulting value again. + // + if (false == awaitable->replay) { + async_waiting_callback_dispose(&callback->callback.base, NULL); + return SUCCESS; + } + + callback->callback.base.dispose = async_waiting_callback_dispose; + awaitable->replay(awaitable, &callback->callback.base, NULL, NULL); + + if (UNEXPECTED(EG(exception))) { + return FAILURE; + } + + return SUCCESS; + } + zend_async_resume_when(await_iterator->waiting_coroutine, awaitable, false, NULL, &callback->callback); + if (UNEXPECTED(EG(exception))) { + async_waiting_callback_dispose(&callback->callback.base, NULL); + return FAILURE; + } + + callback->prev_dispose = callback->callback.base.dispose; + callback->callback.base.dispose = async_waiting_callback_dispose; + + await_context->futures_count++; + return SUCCESS; } -void iterator_coroutine_first_entry(void) +/** + * This function is called when the await_iterator is disposed. + * It cleans up the internal state and releases resources. + * + * @param iterator + * @param concurrent_iterator + */ +static void await_iterator_dispose(async_await_iterator_t * iterator, async_iterator_t *concurrent_iterator) +{ + // If the iterator was completed with an exception, + // pass that exception to the coroutine that is waiting. + if (concurrent_iterator != NULL && concurrent_iterator->exception != NULL) { + zend_object *exception = concurrent_iterator->exception; + concurrent_iterator->exception = NULL; + ZEND_ASYNC_RESUME_WITH_ERROR(iterator->waiting_coroutine, exception, true); + } + + if (iterator->zend_iterator != NULL) { + zend_object_iterator *zend_iterator = iterator->zend_iterator; + iterator->zend_iterator = NULL; + + // When the iterator has finished, it’s now possible to specify the exact number of elements since it’s known. + iterator->await_context->total = iterator->await_context->futures_count; + + // Scenario: the iterator has already finished, and there’s nothing left to await. + // In that case, the coroutine needs to be terminated. + if ((AWAIT_ITERATOR_IS_FINISHED(iterator->await_context) || iterator->await_context->total == 0) + && iterator->waiting_coroutine != NULL + && false == ZEND_ASYNC_WAKER_IN_QUEUE(iterator->waiting_coroutine->waker)) { + ZEND_ASYNC_RESUME(iterator->waiting_coroutine); + } + + if (zend_iterator->funcs->invalidate_current) { + zend_iterator->funcs->invalidate_current(zend_iterator); + } + zend_iterator_dtor(zend_iterator); + } + + efree(iterator); +} + +/** + * This function is called when the internal concurrent iterator is finished. + * It disposes of the await_iterator and cleans up the internal state. + * + * @param internal_iterator + */ +static void await_iterator_finish_callback(zend_async_iterator_t *internal_iterator) +{ + async_await_iterator_iterator_t * iterator = (async_await_iterator_iterator_t *) internal_iterator; + + async_await_iterator_t * await_iterator = iterator->await_iterator; + iterator->await_iterator = NULL; + + await_iterator_dispose(await_iterator, &iterator->iterator); +} + +/** + * This function is called when the iterator coroutine is first entered. + * It initializes the await_iterator and starts the iteration process. + * + * @return void + */ +static void iterator_coroutine_first_entry(void) { zend_coroutine_t *coroutine = ZEND_ASYNC_CURRENT_COROUTINE; @@ -519,6 +666,7 @@ void iterator_coroutine_first_entry(void) } async_await_iterator_t * await_iterator = coroutine->extended_data; + coroutine->extended_data = NULL; ZEND_ASSERT(await_iterator != NULL && "The async_await_iterator_t should not be NULL"); if (UNEXPECTED(await_iterator == NULL)) { @@ -529,6 +677,7 @@ void iterator_coroutine_first_entry(void) async_await_context_t * await_context = await_iterator->await_context; if (UNEXPECTED(await_context == NULL)) { + await_iterator_dispose(await_iterator, NULL); return; } @@ -539,11 +688,15 @@ void iterator_coroutine_first_entry(void) await_iterator_handler, ZEND_ASYNC_CURRENT_SCOPE, await_context->concurrency, - 0, + ZEND_COROUTINE_NORMAL, sizeof(async_await_iterator_iterator_t) ); + iterator->await_iterator = await_iterator; + iterator->iterator.extended_dtor = await_iterator_finish_callback; + if (UNEXPECTED(iterator == NULL)) { + await_iterator_dispose(await_iterator, NULL); return; } @@ -551,15 +704,28 @@ void iterator_coroutine_first_entry(void) iterator->iterator.microtask.dtor(&iterator->iterator.microtask); } -void iterator_coroutine_finish_callback( +/** + * This callback is triggered when the main iteration coroutine finishes. + * It’s needed in case the coroutine gets cancelled. + * In that scenario, extended_data will contain the async_await_iterator_t structure. + * + * @param event + * @param callback + * @param result + * @param exception + */ +static void iterator_coroutine_finish_callback( zend_async_event_t *event, zend_async_event_callback_t *callback, void * result, zend_object *exception ) { - async_await_iterator_t * iterator = (async_await_iterator_t *) - ((zend_coroutine_event_callback_t*) callback)->coroutine->extended_data; + async_await_iterator_t * iterator = (async_await_iterator_t *) ((zend_coroutine_t*) event)->extended_data; + + if (iterator == NULL) { + return; + } if (exception != NULL) { // Resume the waiting coroutine with the exception @@ -568,25 +734,25 @@ void iterator_coroutine_finish_callback( exception, false ); - } else if (ITERATOR_IS_FINISHED(iterator->await_context)) { + } else if (AWAIT_ITERATOR_IS_FINISHED(iterator->await_context)) { // If iteration is finished, resume the waiting coroutine ZEND_ASYNC_RESUME(iterator->waiting_coroutine); } } -void async_await_iterator_coroutine_dispose(zend_coroutine_t *coroutine) +static void async_await_iterator_coroutine_dispose(zend_coroutine_t *coroutine) { if (coroutine == NULL || coroutine->extended_data == NULL) { return; } - async_await_iterator_t * iterator = (async_await_iterator_t *) coroutine->extended_data; + async_await_iterator_t * await_iterator = (async_await_iterator_t *) coroutine->extended_data; coroutine->extended_data = NULL; - efree(iterator); + await_iterator_dispose(await_iterator, NULL); } -void await_context_dtor(async_await_context_t *context) +static void await_context_dtor(async_await_context_t *context) { if (context == NULL) { return; @@ -600,7 +766,7 @@ void await_context_dtor(async_await_context_t *context) efree(context); } -void async_cancel_awaited_futures(async_await_context_t * await_context, HashTable *futures) +static void async_cancel_awaited_futures(async_await_context_t * await_context, HashTable *futures) { zend_coroutine_t *this_coroutine = ZEND_ASYNC_CURRENT_COROUTINE; @@ -630,7 +796,6 @@ void async_cancel_awaited_futures(async_await_context_t * await_context, HashTab zend_async_event_t* awaitable = zval_to_event(current); if (UNEXPECTED(EG(exception))) { - await_context->dtor(await_context); return; } @@ -674,6 +839,23 @@ void async_cancel_awaited_futures(async_await_context_t * await_context, HashTab ZEND_ASYNC_SUSPEND(); } +/** + * This function is used to await multiple futures concurrently. + * It takes an iterable of futures, a count of futures to wait for, + * and various options for handling results and errors. + * + * @param iterable The iterable containing futures (array or Traversable object). + * @param count The number of futures to wait for (0 means all). + * @param ignore_errors Whether to ignore errors in the futures. + * @param cancellation Optional cancellation event. + * @param timeout Timeout for awaiting futures. + * @param concurrency Maximum number of concurrent futures to await. + * @param results HashTable to store results. + * @param errors HashTable to store errors. + * @param fill_missing_with_null Whether to fill missing results with null. + * @param preserve_key_order Whether to preserve the order of keys in results. + * @param cancel_on_exit Whether to cancel awaiting on exit. + */ void async_await_futures( zval *iterable, int count, @@ -684,6 +866,7 @@ void async_await_futures( HashTable *results, HashTable *errors, bool fill_missing_with_null, + bool preserve_key_order, bool cancel_on_exit ) { @@ -721,25 +904,33 @@ void async_await_futures( zend_coroutine_t *coroutine = ZEND_ASYNC_CURRENT_COROUTINE; if (UNEXPECTED(coroutine == NULL)) { + if (zend_iterator != NULL) { + zend_iterator_dtor(zend_iterator); + } async_throw_error("Cannot await futures outside of a coroutine"); return; } if (UNEXPECTED(zend_async_waker_new_with_timeout(coroutine, timeout, cancellation) == NULL)) { + if (zend_iterator != NULL) { + zend_iterator_dtor(zend_iterator); + } return; } await_context = ecalloc(1, sizeof(async_await_context_t)); await_context->total = futures != NULL ? (int) zend_hash_num_elements(futures) : 0; + await_context->futures_count = 0; await_context->waiting_count = count > 0 ? count : await_context->total; await_context->resolved_count = 0; await_context->success_count = 0; await_context->ignore_errors = ignore_errors; await_context->concurrency = concurrency; await_context->fill_missing_with_null = fill_missing_with_null; + await_context->preserve_key_order = preserve_key_order; await_context->cancel_on_exit = cancel_on_exit; - if (AWAIT_ALL(await_context)) { + if (preserve_key_order && false == fill_missing_with_null) { tmp_results = zend_new_array(await_context->total); await_context->results = tmp_results; } else { @@ -752,6 +943,10 @@ void async_await_futures( if (futures != NULL) { + zval undef_val; + // The PRT NULL type is used to fill the array with empty elements that will later be removed. + ZVAL_PTR(&undef_val, NULL); + ZEND_HASH_FOREACH_KEY_VAL(futures, index, key, current) { // An array element can be either an object implementing @@ -780,19 +975,19 @@ void async_await_futures( ZVAL_STR(&callback->key, key); zval_add_ref(&callback->key); - if (await_context->results != NULL && AWAIT_ALL(await_context)) { + if (await_context->results != NULL && fill_missing_with_null) { zend_hash_add_empty_element(await_context->results, key); - } else if (await_context->results != NULL && await_context->fill_missing_with_null) { - zend_hash_add(await_context->results, key, &EG(uninitialized_zval)); + } else if (await_context->results != NULL && preserve_key_order) { + zend_hash_add(await_context->results, key, &undef_val); } } else { ZVAL_LONG(&callback->key, index); - if (await_context->results != NULL && AWAIT_ALL(await_context)) { + if (await_context->results != NULL && fill_missing_with_null) { zend_hash_index_add_empty_element(await_context->results, index); - } else if (await_context->results != NULL && await_context->fill_missing_with_null) { - zend_hash_index_add_new(await_context->results, index, &EG(uninitialized_zval)); + } else if (await_context->results != NULL && preserve_key_order) { + zend_hash_index_add_new(await_context->results, index, &undef_val); } } @@ -818,14 +1013,17 @@ void async_await_futures( zend_async_scope_t * scope = ZEND_ASYNC_NEW_SCOPE(ZEND_ASYNC_CURRENT_SCOPE); if (UNEXPECTED(scope == NULL || EG(exception))) { + zend_iterator_dtor(zend_iterator); await_context->dtor(await_context); return; } - zend_coroutine_t * iterator_coroutine = ZEND_ASYNC_SPAWN_WITH(scope); + zend_coroutine_t * iterator_coroutine = ZEND_ASYNC_SPAWN_WITH_SCOPE_EX(scope, ZEND_COROUTINE_NORMAL); if (UNEXPECTED(iterator_coroutine == NULL || EG(exception))) { + zend_iterator_dtor(zend_iterator); await_context->dtor(await_context); + scope->try_to_dispose(scope); return; } @@ -836,6 +1034,7 @@ void async_await_futures( iterator->zend_iterator = zend_iterator; iterator->waiting_coroutine = coroutine; iterator->iterator_coroutine = iterator_coroutine; + iterator->await_context = await_context; iterator_coroutine->extended_data = iterator; iterator_coroutine->extended_dispose = async_await_iterator_coroutine_dispose; @@ -843,9 +1042,17 @@ void async_await_futures( zend_async_resume_when( coroutine, &iterator_coroutine->event, false, iterator_coroutine_finish_callback, NULL ); + + if (UNEXPECTED(EG(exception))) { + // At this point, we don’t free the iterator + // because it now belongs to the coroutine and must be destroyed there. + return; + } } - ZEND_ASYNC_SUSPEND(); + if (coroutine->waker->events.nNumOfElements > 0) { + ZEND_ASYNC_SUSPEND(); + } // If the await on futures has completed and // the automatic cancellation mode for pending coroutines is active. @@ -853,19 +1060,13 @@ void async_await_futures( async_cancel_awaited_futures(await_context, futures); } - // Free the coroutine scope if it was created for the iterator. - if (await_context->scope != NULL) { - await_context->scope->try_to_dispose(await_context->scope); - await_context->scope = NULL; - } - // Remove all undefined buckets from the results array. if (tmp_results != NULL) { // foreach results as key => value - // if value is UNDEFINED then continue + // if value is PTR then continue ZEND_HASH_FOREACH_KEY_VAL(tmp_results, index, key, current) { - if (Z_TYPE_P(current) == IS_UNDEF) { + if (Z_TYPE_P(current) == IS_PTR && Z_PTR_P(current) == NULL) { continue; } diff --git a/async_API.h b/async_API.h index 6f51da0..45be9ca 100644 --- a/async_API.h +++ b/async_API.h @@ -29,6 +29,9 @@ struct _async_await_context_t unsigned int ref_count; /* The total number of futures to wait for */ unsigned int total; + /* The current number of futures being awaited. + * This counter is used in the case of a zend_iterator, since the total number of elements is unknown. */ + unsigned int futures_count; /* The number of futures that are currently waiting */ unsigned int waiting_count; /* The number of futures that have been resolved */ @@ -39,6 +42,8 @@ struct _async_await_context_t bool ignore_errors; /* If we need to fill missing results with null */ bool fill_missing_with_null; + /* If we need to preserve key order in results */ + bool preserve_key_order; /* * The flag indicates that all pending coroutines * must be cancelled once the wait completes, regardless of the outcome. @@ -90,6 +95,7 @@ void async_await_futures( HashTable *results, HashTable *errors, bool fill_missing_with_null, + bool preserve_key_order, bool cancel_on_exit ); diff --git a/async_arginfo.h b/async_arginfo.h index 76853b1..13f4911 100644 --- a/async_arginfo.h +++ b/async_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: a36a9014653f09bffce4905a89824544b244b409 */ + * Stub hash: d217fc8dbb5aa518add60c4d99d3e7a356fbd41f */ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_Async_spawn, 0, 1, Async\\Coroutine, 0) ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) @@ -15,7 +15,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_suspend, 0, 0, IS_VOID, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_protect, 0, 1, IS_VOID, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_protect, 0, 1, IS_MIXED, 0) ZEND_ARG_OBJ_INFO(0, closure, Closure, 0) ZEND_END_ARG_INFO() @@ -34,18 +34,30 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_awaitAll, 0, 1, IS_ARRAY, 0) ZEND_ARG_OBJ_TYPE_MASK(0, triggers, Traversable, MAY_BE_ARRAY, NULL) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, cancellation, Async\\Awaitable, 1, "null") - ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, fillNull, _IS_BOOL, 0, "false") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, preserveKeyOrder, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() -#define arginfo_Async_awaitAllWithErrors arginfo_Async_awaitAll +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_awaitAllWithErrors, 0, 1, IS_ARRAY, 0) + ZEND_ARG_OBJ_TYPE_MASK(0, triggers, Traversable, MAY_BE_ARRAY, NULL) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, cancellation, Async\\Awaitable, 1, "null") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, preserveKeyOrder, _IS_BOOL, 0, "true") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, fillNull, _IS_BOOL, 0, "false") +ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_awaitAnyOf, 0, 2, IS_ARRAY, 0) ZEND_ARG_TYPE_INFO(0, count, IS_LONG, 0) ZEND_ARG_OBJ_TYPE_MASK(0, triggers, Traversable, MAY_BE_ARRAY, NULL) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, cancellation, Async\\Awaitable, 1, "null") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, preserveKeyOrder, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() -#define arginfo_Async_awaitAnyOfWithErrors arginfo_Async_awaitAnyOf +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_awaitAnyOfWithErrors, 0, 2, IS_ARRAY, 0) + ZEND_ARG_TYPE_INFO(0, count, IS_LONG, 0) + ZEND_ARG_OBJ_TYPE_MASK(0, triggers, Traversable, MAY_BE_ARRAY, NULL) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, cancellation, Async\\Awaitable, 1, "null") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, preserveKeyOrder, _IS_BOOL, 0, "true") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, fillNull, _IS_BOOL, 0, "false") +ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_delay, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, ms, IS_LONG, 0) diff --git a/coroutine.c b/coroutine.c index e7452df..07c3529 100644 --- a/coroutine.c +++ b/coroutine.c @@ -96,9 +96,8 @@ METHOD(getException) async_coroutine_t *coroutine = THIS_COROUTINE; - if (!ZEND_COROUTINE_IS_FINISHED(&coroutine->coroutine)) { - async_throw_error("Cannot get exception of a running coroutine"); - RETURN_THROWS(); + if (false == ZEND_COROUTINE_IS_FINISHED(&coroutine->coroutine)) { + RETURN_NULL(); } if (coroutine->coroutine.exception == NULL) { @@ -220,7 +219,8 @@ METHOD(isCancelled) { ZEND_PARSE_PARAMETERS_NONE(); - RETURN_BOOL(ZEND_COROUTINE_IS_CANCELLED(&THIS_COROUTINE->coroutine)); + RETURN_BOOL(ZEND_COROUTINE_IS_CANCELLED(&THIS_COROUTINE->coroutine) + && ZEND_COROUTINE_IS_FINISHED(&THIS_COROUTINE->coroutine)); } METHOD(isCancellationRequested) @@ -229,9 +229,9 @@ METHOD(isCancellationRequested) async_coroutine_t *coroutine = THIS_COROUTINE; - // TODO: Implement cancellation request tracking in waker - // For now, return same as isCancelled - RETURN_BOOL(ZEND_COROUTINE_IS_CANCELLED(&coroutine->coroutine)); + RETURN_BOOL((ZEND_COROUTINE_IS_CANCELLED(&coroutine->coroutine) + && !ZEND_COROUTINE_IS_FINISHED(&coroutine->coroutine)) + || coroutine->deferred_cancellation != NULL); } METHOD(isFinished) @@ -379,12 +379,11 @@ static void finally_handlers_iterator_dtor(zend_async_iterator_t *zend_iterator) } finally_handlers_context_t *context = iterator->extended_data; + async_scope_t *scope = (async_scope_t *) context->scope; + context->scope = NULL; // Throw CompositeException if any exceptions were collected if (context->composite_exception != NULL) { - - async_scope_t *scope = (async_scope_t *) context->scope; - if (ZEND_ASYNC_SCOPE_CATCH( &scope->scope, &context->coroutine->coroutine, @@ -394,13 +393,13 @@ static void finally_handlers_iterator_dtor(zend_async_iterator_t *zend_iterator) ZEND_ASYNC_SCOPE_IS_DISPOSE_SAFELY(&scope->scope) )) { OBJ_RELEASE(context->composite_exception); - } else { - async_rethrow_exception(context->composite_exception); + context->composite_exception = NULL; } - - context->composite_exception = NULL; } + zend_object * composite_exception = context->composite_exception; + context->composite_exception = NULL; + if (context->dtor != NULL) { context->dtor(context); context->dtor = NULL; @@ -410,6 +409,18 @@ static void finally_handlers_iterator_dtor(zend_async_iterator_t *zend_iterator) efree(context); iterator->extended_data = NULL; + if (ZEND_ASYNC_EVENT_REF(&scope->scope.event) > 0) { + ZEND_ASYNC_EVENT_DEL_REF(&scope->scope.event); + + if (ZEND_ASYNC_EVENT_REF(&scope->scope.event) <= 1) { + scope->scope.try_to_dispose(&scope->scope); + } + } + + if (composite_exception != NULL) { + async_rethrow_exception(composite_exception); + } + // // If everything is correct, // the Scope will destroy itself as soon as the coroutine created within it completes execution. @@ -454,6 +465,14 @@ bool async_call_finally_handlers(HashTable *finally_handlers, finally_handlers_c iterator->extended_dtor = finally_handlers_iterator_dtor; async_iterator_run_in_coroutine(iterator, priority); + // + // We retain ownership of the Scope in order to be able to handle exceptions from the Finally handlers. + // example: finally_handlers_iterator_dtor + // If the onFinally handlers throw an exception, it will end up in the Scope, + // so it’s important that the Scope is not destroyed before that moment. + // + ZEND_ASYNC_EVENT_ADD_REF(&context->scope->event); + if (UNEXPECTED(EG(exception))) { return false; } @@ -515,6 +534,13 @@ static zend_always_inline void coroutine_call_finally_handlers(async_coroutine_t void async_coroutine_finalize(zend_fiber_transfer *transfer, async_coroutine_t * coroutine) { + // Before finalizing the coroutine + // we check that we’re properly finishing the coroutine’s execution. + // The coroutine must not be in the queue! + if (UNEXPECTED(ZEND_ASYNC_WAKER_IN_QUEUE(coroutine->coroutine.waker))) { + zend_error(E_CORE_WARNING, "Attempt to finalize a coroutine that is still in the queue"); + } + ZEND_COROUTINE_SET_FINISHED(&coroutine->coroutine); /* Call switch handlers for coroutine finishing */ @@ -552,13 +578,15 @@ void async_coroutine_finalize(zend_fiber_transfer *transfer, async_coroutine_t * // Hold the exception inside coroutine if it is not NULL. if (exception != NULL) { if (coroutine->coroutine.exception != NULL) { - // If the coroutine already has an exception, we do not overwrite it. - // This is to prevent losing the original exception in case of multiple exceptions. - zend_exception_set_previous(exception, coroutine->coroutine.exception); + if (false == instanceof_function(exception->ce, zend_ce_cancellation_exception)) { + zend_exception_set_previous(exception, coroutine->coroutine.exception); + coroutine->coroutine.exception = exception; + GC_ADDREF(exception); + } + } else { + coroutine->coroutine.exception = exception; + GC_ADDREF(exception); } - - coroutine->coroutine.exception = exception; - GC_ADDREF(exception); } else if (coroutine->coroutine.exception != NULL) { // If the coroutine has an exception, we keep it. exception = coroutine->coroutine.exception; @@ -682,6 +710,7 @@ void async_coroutine_finalize_from_scheduler(async_coroutine_t * coroutine) EG(prev_exception) = NULL; waker->error = NULL; + waker->status = ZEND_ASYNC_WAKER_NO_STATUS; bool do_bailout = false; @@ -931,14 +960,25 @@ void async_coroutine_resume(zend_coroutine_t *coroutine, zend_object * error, co if (error != NULL) { if (coroutine->waker->error != NULL) { - zend_exception_set_previous(error, coroutine->waker->error); - OBJ_RELEASE(coroutine->waker->error); - } - coroutine->waker->error = error; + if (false == instanceof_function(error->ce, zend_ce_cancellation_exception)) { + zend_exception_set_previous(error, coroutine->waker->error); + coroutine->waker->error = error; + + if (false == transfer_error) { + GC_ADDREF(error); + } + } else { + if (transfer_error) { + OBJ_RELEASE(error); + } + } + } else { + coroutine->waker->error = error; - if (false == transfer_error) { - GC_ADDREF(error); + if (false == transfer_error) { + GC_ADDREF(error); + } } } @@ -965,8 +1005,28 @@ void async_coroutine_cancel(zend_coroutine_t *zend_coroutine, zend_object *error return; } - if (ZEND_ASYNC_SCHEDULER_CONTEXT && zend_coroutine == ZEND_ASYNC_CURRENT_COROUTINE) { - zend_throw_error(zend_ce_cancellation_exception, "Coroutine has been canceled"); + // An attempt to cancel a coroutine that is currently running. + // In this case, nothing actually happens immediately; + // however, the coroutine is marked as having been cancelled, + // and the cancellation exception is stored as its result. + if (UNEXPECTED(zend_coroutine == ZEND_ASYNC_CURRENT_COROUTINE)) { + + ZEND_COROUTINE_SET_CANCELLED(zend_coroutine); + + if (zend_coroutine->exception == NULL) { + zend_coroutine->exception = error; + + if (false == transfer_error) { + GC_ADDREF(error); + } + } + + if (zend_coroutine->exception == NULL) { + zend_coroutine->exception = async_new_exception( + async_ce_cancellation_exception, "Coroutine cancelled" + ); + } + return; } @@ -1039,7 +1099,6 @@ void async_coroutine_cancel(zend_coroutine_t *zend_coroutine, zend_object *error // In any other case, the cancellation exception overrides the existing exception. // ZEND_ASYNC_WAKER_APPLY_CANCELLATION(waker, error, transfer_error); - ZEND_ASYNC_DECREASE_COROUTINE_COUNT; async_scheduler_coroutine_enqueue(zend_coroutine); return; } diff --git a/exceptions.c b/exceptions.c index 0d7e469..cfa9a0e 100644 --- a/exceptions.c +++ b/exceptions.c @@ -281,6 +281,49 @@ bool async_spawn_and_throw(zend_object *exception, zend_async_scope_t *scope, in return true; } +/** + * Extracts the current exception from the global state, saves it, and clears it. + * + * @return The extracted exception object with an increased reference count. + */ +zend_object * async_extract_exception(void) +{ + zend_exception_save(); + zend_exception_restore(); + zend_object *exception = EG(exception); + GC_ADDREF(exception); + zend_clear_exception(); + + return exception; +} + +/** + * Applies the current exception to the provided exception pointer. + * + * If the current exception is not a cancellation exception or a graceful/unwind exit, + * it extracts the current exception and sets it as the new exception. + * If `to_exception` is not NULL, it sets the previous exception to the extracted one. + * + * @param to_exception Pointer to a pointer where the new exception will be set. + */ +void async_apply_exception(zend_object **to_exception) +{ + if (UNEXPECTED(EG(exception) + && false == ( + instanceof_function(EG(exception)->ce, zend_ce_cancellation_exception) + || zend_is_graceful_exit(EG(exception)) || zend_is_unwind_exit(EG(exception)) + ))) { + + zend_object *exception = async_extract_exception(); + + if (*to_exception != NULL) { + zend_exception_set_previous(exception, *to_exception); + } + + *to_exception = exception; + } +} + void async_rethrow_exception(zend_object *exception) { if (EG(current_execute_data)) { diff --git a/exceptions.h b/exceptions.h index c8b6dc0..a1222f7 100644 --- a/exceptions.h +++ b/exceptions.h @@ -41,7 +41,9 @@ ZEND_API ZEND_COLD zend_object * async_new_composite_exception(void); ZEND_API void async_composite_exception_add_exception(zend_object *composite, zend_object *exception, bool transfer); bool async_spawn_and_throw(zend_object *exception, zend_async_scope_t *scope, int32_t priority); void async_apply_exception_to_context(zend_object *exception); +zend_object * async_extract_exception(void); void async_rethrow_exception(zend_object *exception); +void async_apply_exception(zend_object **to_exception); END_EXTERN_C() diff --git a/iterator.c b/iterator.c index 622b03e..72346aa 100644 --- a/iterator.c +++ b/iterator.c @@ -39,7 +39,8 @@ void iterator_microtask(zend_async_microtask_t *microtask) { async_iterator_t *iterator = (async_iterator_t *) microtask; - if (iterator->state == ASYNC_ITERATOR_FINISHED || iterator->active_coroutines >= iterator->concurrency) { + if (iterator->state == ASYNC_ITERATOR_FINISHED + || (iterator->concurrency > 0 && iterator->active_coroutines >= iterator->concurrency)) { return; } @@ -98,9 +99,36 @@ void iterator_dtor(zend_async_microtask_t *microtask) iterator->fcall = NULL; } + if (iterator->exception != NULL) { + zend_object *exception = iterator->exception; + iterator->exception = NULL; + OBJ_RELEASE(exception); + } + efree(microtask); } +// +// Start of the block for safe iterator modification. +// +// Safe iterator modification means making changes during which no new iterator coroutines will be created, +// because the iterator’s state is undefined. +// +#define ITERATOR_SAFE_MOVING_START(iterator) \ + (iterator)->state = ASYNC_ITERATOR_MOVING; \ + (iterator)->microtask.is_cancelled = true; \ + uint32_t prev_ref_count = (iterator)->microtask.ref_count; + +// +// End of the block for safe iterator modification. +// +#define ITERATOR_SAFE_MOVING_END(iterator) \ + (iterator)->state = ASYNC_ITERATOR_STARTED; \ + if (prev_ref_count != (iterator)->microtask.ref_count) { \ + (iterator)->microtask.is_cancelled = false; \ + ZEND_ASYNC_ADD_MICROTASK(&(iterator)->microtask); \ + } + async_iterator_t * async_iterator_new( zval *array, zend_object_iterator *zend_iterator, @@ -133,6 +161,7 @@ async_iterator_t * async_iterator_new( if (scope == NULL) { scope = ZEND_ASYNC_CURRENT_SCOPE; } + iterator->scope = scope; if (zend_iterator == NULL) { @@ -156,12 +185,35 @@ async_iterator_t * async_iterator_new( return iterator; } +#define RETURN_IF_EXCEPTION(iterator) \ + if (UNEXPECTED(EG(exception))) { \ + iterator->state = ASYNC_ITERATOR_FINISHED; \ + iterator->microtask.is_cancelled = true; \ + return; \ + } + +#define RETURN_IF_EXCEPTION_AND(iterator, and) \ + if (UNEXPECTED(EG(exception))) { \ + iterator->state = ASYNC_ITERATOR_FINISHED; \ + iterator->microtask.is_cancelled = true; \ + and; \ + return; \ + } + static zend_always_inline void iterate(async_iterator_t *iterator) { zend_result result = SUCCESS; zval retval; ZVAL_UNDEF(&retval); + if (UNEXPECTED(iterator->state == ASYNC_ITERATOR_MOVING)) { + // The iterator is in a state of waiting for a position change. + // The coroutine cannot continue execution because + // it cannot move the iterator to the next position. + // We exit immediately. + return; + } + zend_fcall_info fci; // Copy the fci to avoid overwriting the original @@ -194,17 +246,48 @@ static zend_always_inline void iterate(async_iterator_t *iterator) // or just set it to the array iterator->target_hash = Z_ARRVAL(iterator->array); } + } else if (iterator->state == ASYNC_ITERATOR_INIT) { + iterator->state = ASYNC_ITERATOR_STARTED; + iterator->position = 0; + iterator->hash_iterator = -1; + + if (iterator->zend_iterator->funcs->rewind) { + ITERATOR_SAFE_MOVING_START(iterator) { + iterator->zend_iterator->funcs->rewind(iterator->zend_iterator); + } ITERATOR_SAFE_MOVING_END(iterator); + } + + RETURN_IF_EXCEPTION(iterator); } zval * current; + zval current_item; zval key; + ZVAL_UNDEF(¤t_item); while (iterator->state != ASYNC_ITERATOR_FINISHED) { + if (iterator->state == ASYNC_ITERATOR_MOVING) { + // The iterator is in a state of waiting for a position change. + // The coroutine cannot continue execution because + // it cannot move the iterator to the next position. + break; + } + if (iterator->target_hash != NULL) { current = zend_hash_get_current_data_ex(iterator->target_hash, &iterator->position); } else if (SUCCESS == iterator->zend_iterator->funcs->valid(iterator->zend_iterator)) { - current = iterator->zend_iterator->funcs->get_current_data(iterator->zend_iterator); + + RETURN_IF_EXCEPTION(iterator); + ITERATOR_SAFE_MOVING_START(iterator) { + current = iterator->zend_iterator->funcs->get_current_data(iterator->zend_iterator); + } ITERATOR_SAFE_MOVING_END(iterator); + RETURN_IF_EXCEPTION(iterator); + + if (current != NULL) { + ZVAL_COPY(¤t_item, current); + current = ¤t_item; + } } else { current = NULL; } @@ -217,26 +300,39 @@ static zend_always_inline void iterate(async_iterator_t *iterator) /* Skip undefined indirect elements */ if (Z_TYPE_P(current) == IS_INDIRECT) { + current = Z_INDIRECT_P(current); + zval_ptr_dtor(¤t_item); + if (Z_TYPE_P(current) == IS_UNDEF) { if (iterator->zend_iterator == NULL) { zend_hash_move_forward(Z_ARR(iterator->array)); } else { - iterator->zend_iterator->funcs->move_forward(iterator->zend_iterator); + + if (iterator->state == ASYNC_ITERATOR_MOVING) { + return; + } + + ITERATOR_SAFE_MOVING_START(iterator) { + iterator->zend_iterator->funcs->move_forward(iterator->zend_iterator); + } ITERATOR_SAFE_MOVING_END(iterator); + + RETURN_IF_EXCEPTION(iterator); } continue; } } - /* Ensure the value is a reference. Otherwise, the location of the value may be freed. */ - ZVAL_MAKE_REF(current); - /* Retrieve key */ if (iterator->target_hash != NULL) { zend_hash_get_current_key_zval_ex(iterator->target_hash, &key, &iterator->position); } else { - iterator->zend_iterator->funcs->get_current_key(iterator->zend_iterator, &key); + ITERATOR_SAFE_MOVING_START(iterator) { + iterator->zend_iterator->funcs->get_current_key(iterator->zend_iterator, &key); + } ITERATOR_SAFE_MOVING_END(iterator); + + RETURN_IF_EXCEPTION_AND(iterator, zval_ptr_dtor(¤t_item)); } /* @@ -248,7 +344,12 @@ static zend_always_inline void iterate(async_iterator_t *iterator) // And update the iterator position EG(ht_iterators)[iterator->hash_iterator].pos = iterator->position; } else { - iterator->zend_iterator->funcs->move_forward(iterator->zend_iterator); + + ITERATOR_SAFE_MOVING_START(iterator) { + iterator->zend_iterator->funcs->move_forward(iterator->zend_iterator); + } ITERATOR_SAFE_MOVING_END(iterator); + + RETURN_IF_EXCEPTION_AND(iterator, zval_ptr_dtor(¤t_item); zval_ptr_dtor(&key)); } if (iterator->fcall != NULL) { @@ -261,6 +362,9 @@ static zend_always_inline void iterate(async_iterator_t *iterator) result = iterator->handler(iterator, current, &key); } + zval_ptr_dtor(¤t_item); + zval_ptr_dtor(&key); + if (result == SUCCESS) { if (Z_TYPE(retval) == IS_FALSE) { @@ -335,6 +439,7 @@ void async_iterator_run(async_iterator_t *iterator) ZEND_ASYNC_ADD_MICROTASK(&iterator->microtask); iterate(iterator); + async_iterator_apply_exception(iterator); } /** @@ -355,4 +460,20 @@ void async_iterator_run_in_coroutine(async_iterator_t *iterator, int32_t priorit iterator_coroutine->extended_data = iterator; iterator_coroutine->internal_entry = coroutine_entry; iterator_coroutine->extended_dispose = coroutine_extended_dispose; +} + +void async_iterator_apply_exception(async_iterator_t *iterator) +{ + async_apply_exception(&iterator->exception); + + if (iterator->exception == NULL || ZEND_ASYNC_SCOPE_IS_CANCELLED(iterator->scope)) { + return; + } + + ZEND_ASYNC_SCOPE_CANCEL( + iterator->scope, + async_new_exception(async_ce_cancellation_exception, "Cancellation of the iterator due to an exception"), + true, + ZEND_ASYNC_SCOPE_IS_DISPOSE_SAFELY(iterator->scope) + ); } \ No newline at end of file diff --git a/iterator.h b/iterator.h index 9503ecf..4043e5c 100644 --- a/iterator.h +++ b/iterator.h @@ -27,8 +27,9 @@ typedef zend_result (*async_iterator_handler_t)(async_iterator_t *iterator, zval typedef enum { ASYNC_ITERATOR_INIT = 0, - ASYNC_ITERATOR_STARTED = 1, - ASYNC_ITERATOR_FINISHED = 2, + ASYNC_ITERATOR_MOVING, + ASYNC_ITERATOR_STARTED, + ASYNC_ITERATOR_FINISHED, } async_iterator_state_t; async_iterator_t * async_iterator_new( @@ -46,6 +47,7 @@ async_iterator_t * async_iterator_new( void async_iterator_run(async_iterator_t *iterator); void async_iterator_run_in_coroutine(async_iterator_t *iterator, int32_t priority); +void async_iterator_apply_exception(async_iterator_t *iterator); struct _async_iterator_t { ZEND_ASYNC_ITERATOR_FIELDS diff --git a/scheduler.c b/scheduler.c index 10d6618..46db144 100644 --- a/scheduler.c +++ b/scheduler.c @@ -471,7 +471,6 @@ void start_graceful_shutdown(void) if (UNEXPECTED(EG(exception) != NULL)) { zend_exception_set_previous(EG(exception), ZEND_ASYNC_EXIT_EXCEPTION); - GC_DELREF(ZEND_ASYNC_EXIT_EXCEPTION); ZEND_ASYNC_EXIT_EXCEPTION = EG(exception); GC_ADDREF(EG(exception)); zend_clear_exception(); @@ -484,7 +483,6 @@ static void finally_shutdown(void) { if (ZEND_ASYNC_EXIT_EXCEPTION != NULL && EG(exception) != NULL) { zend_exception_set_previous(EG(exception), ZEND_ASYNC_EXIT_EXCEPTION); - GC_DELREF(ZEND_ASYNC_EXIT_EXCEPTION); ZEND_ASYNC_EXIT_EXCEPTION = EG(exception); GC_ADDREF(EG(exception)); zend_clear_exception(); @@ -498,7 +496,6 @@ static void finally_shutdown(void) if (UNEXPECTED(EG(exception))) { if (ZEND_ASYNC_EXIT_EXCEPTION != NULL) { zend_exception_set_previous(EG(exception), ZEND_ASYNC_EXIT_EXCEPTION); - GC_DELREF(ZEND_ASYNC_EXIT_EXCEPTION); ZEND_ASYNC_EXIT_EXCEPTION = EG(exception); GC_ADDREF(EG(exception)); } @@ -709,7 +706,6 @@ void async_scheduler_main_coroutine_suspend(void) // if (EG(exception) != NULL && exit_exception != NULL) { zend_exception_set_previous(EG(exception), exit_exception); - GC_DELREF(exit_exception); } else if (exit_exception != NULL) { async_rethrow_exception(exit_exception); } @@ -719,6 +715,8 @@ void async_scheduler_main_coroutine_suspend(void) if (UNEXPECTED(EG(exception) != NULL)) { \ if(ZEND_ASYNC_GRACEFUL_SHUTDOWN) { \ finally_shutdown(); \ + switch_to_scheduler(transfer); \ + zend_exception_restore(); \ return; \ } \ start_graceful_shutdown(); \ @@ -799,6 +797,17 @@ void async_scheduler_coroutine_suspend(zend_fiber_transfer *transfer) // This causes timers to start, POLL objects to begin waiting for events, and so on. // if (transfer == NULL && coroutine != NULL && coroutine->waker != NULL) { + + // Let’s check that the coroutine has something to wait for; + // If a coroutine isn’t waiting for anything, it must be in the execution queue. + // otherwise, it’s a potential deadlock. + if (coroutine->waker->events.nNumOfElements == 0 && false == ZEND_ASYNC_WAKER_IN_QUEUE(coroutine->waker)) { + async_throw_error("The coroutine has no events to wait for"); + zend_async_waker_destroy(coroutine); + zend_exception_restore(); + return; + } + async_scheduler_start_waker_events(coroutine->waker); // If an exception occurs during the startup of the Waker object, @@ -842,7 +851,6 @@ void async_scheduler_coroutine_suspend(zend_fiber_transfer *transfer) if (ZEND_ASYNC_EXIT_EXCEPTION != NULL) { zend_exception_set_previous(exception, ZEND_ASYNC_EXIT_EXCEPTION); - GC_DELREF(ZEND_ASYNC_EXIT_EXCEPTION); ZEND_ASYNC_EXIT_EXCEPTION = exception; } else { ZEND_ASYNC_EXIT_EXCEPTION = exception; @@ -969,7 +977,6 @@ void async_scheduler_main_loop(void) if (EG(exception) != NULL && exit_exception != NULL) { zend_exception_set_previous(EG(exception), exit_exception); - GC_DELREF(exit_exception); exit_exception = EG(exception); GC_ADDREF(exit_exception); zend_clear_exception(); diff --git a/scope.c b/scope.c index 9814047..6458363 100644 --- a/scope.c +++ b/scope.c @@ -229,7 +229,7 @@ METHOD(spawn) } if (UNEXPECTED(ZEND_ASYNC_SCOPE_IS_CLOSED(&scope_object->scope->scope))) { - async_throw_error("Cannot spawn coroutine in a closed scope"); + async_throw_error("Cannot spawn a coroutine in a closed scope"); RETURN_THROWS(); } @@ -445,6 +445,18 @@ METHOD(isClosed) RETURN_BOOL(ZEND_ASYNC_SCOPE_IS_CLOSED(&scope_object->scope->scope)); } +METHOD(isCancelled) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + async_scope_object_t *scope_object = THIS_SCOPE; + if (UNEXPECTED(scope_object->scope == NULL)) { + RETURN_BOOL(scope_object->is_cancelled); + } + + RETURN_BOOL(ZEND_ASYNC_SCOPE_IS_CANCELLED(&scope_object->scope->scope)); +} + METHOD(setExceptionHandler) { zend_fcall_info fci; @@ -889,6 +901,10 @@ static bool scope_catch_or_cancel( ZEND_ASYNC_SCOPE_SET_CANCELLED(&async_scope->scope); + if (((async_scope_object_t *)async_scope->scope.scope_object) != NULL) { + ((async_scope_object_t *)async_scope->scope.scope_object)->is_cancelled = true; + } + // If an unexpected exception occurs during the function's execution, we combine them into one. if (EG(exception)) { exception = zend_exception_merge(exception, true, transfer_error); @@ -974,20 +990,29 @@ static bool scope_try_to_dispose(zend_async_scope_t *scope) { async_scope_t *async_scope = (async_scope_t *) scope; + if (ZEND_ASYNC_SCOPE_IS_DISPOSING(scope)) { + return true; + } + if (false == SCOPE_CAN_BE_DISPOSED(async_scope)) { return false; } + ZEND_ASYNC_SCOPE_SET_DISPOSING(scope); + // Dispose all child scopes for (uint32_t i = 0; i < async_scope->scope.scopes.length; ++i) { async_scope_t *child_scope = (async_scope_t *) async_scope->scope.scopes.data[i]; child_scope->scope.event.dispose(&child_scope->scope.event); if (UNEXPECTED(EG(exception))) { + ZEND_ASYNC_SCOPE_CLR_DISPOSING(scope); // If an exception occurs during child scope disposal, we stop further processing return false; } } + ZEND_ASYNC_SCOPE_CLR_DISPOSING(scope); + // Dispose the scope async_scope->scope.event.dispose(&async_scope->scope.event); return true; @@ -1088,10 +1113,10 @@ static void scope_dispose(zend_async_event_t *scope_event) zend_async_scope_remove_child(scope->scope.parent_scope, &scope->scope); } - // Clear weak reference from context to scope if (scope->scope.context != NULL) { async_context_t *context = (async_context_t *) scope->scope.context; context->scope = NULL; + OBJ_RELEASE(&context->std); } if (scope->scope.scope_object != NULL) { @@ -1123,7 +1148,8 @@ static void scope_dispose(zend_async_event_t *scope_event) FREE_HASHTABLE(scope->finally_handlers); scope->finally_handlers = NULL; } - + + zend_async_callbacks_free(&scope->scope.event); async_scope_free_coroutines(scope); zend_async_scope_free_children(&scope->scope); efree(scope); @@ -1148,6 +1174,7 @@ zend_async_scope_t * async_new_scope(zend_async_scope_t * parent_scope, const bo } scope_object->scope = scope; + scope_object->is_cancelled = false; scope->scope.scope_object = &scope_object->std; } @@ -1508,12 +1535,6 @@ static zend_always_inline bool try_to_handle_exception( static void async_scope_call_finally_handlers_dtor(finally_handlers_context_t *context) { - zend_async_scope_t *scope = context->target; - if (ZEND_ASYNC_EVENT_REF(&scope->event) > 0) { - ZEND_ASYNC_EVENT_DEL_REF(&scope->event); - } - - scope->try_to_dispose(scope); context->target = NULL; } @@ -1542,7 +1563,6 @@ static bool async_scope_call_finally_handlers(async_scope_t *scope) zend_array_destroy(finally_handlers); return false; } else { - ZEND_ASYNC_EVENT_ADD_REF(&scope->scope.event); return true; } } \ No newline at end of file diff --git a/scope.h b/scope.h index 57226cf..3173d2d 100644 --- a/scope.h +++ b/scope.h @@ -60,6 +60,7 @@ typedef struct _async_scope_object_s { struct { char _padding[sizeof(zend_object) - sizeof(zval)]; async_scope_t *scope; + bool is_cancelled; /* Indicates if the scope is cancelled */ }; }; } async_scope_object_t; diff --git a/scope.stub.php b/scope.stub.php index 8d4dc33..8d8e653 100644 --- a/scope.stub.php +++ b/scope.stub.php @@ -35,8 +35,6 @@ public function afterCoroutineEnqueue(Coroutine $coroutine, Scope $scope): void; */ final class Scope implements ScopeProvider { - //public readonly Context $context; - /** * Creates a new Scope that inherits from the specified one. If the parameter is not provided, * the Scope inherits from the current one. @@ -65,6 +63,8 @@ public function isFinished(): bool {} public function isClosed(): bool {} + public function isCancelled(): bool {} + /** * Sets an error handler that is called when an exception is passed to the Scope from one of its child coroutines. */ diff --git a/scope_arginfo.h b/scope_arginfo.h index 742b7d5..a751957 100644 --- a/scope_arginfo.h +++ b/scope_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 6106b3351f8094535fee7310e1d096ed06fb813d */ + * Stub hash: 655728a28912cc420f1fe0ae483291cff8153fdc */ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Async_ScopeProvider_provideScope, 0, 0, Async\\Scope, 1) ZEND_END_ARG_INFO() @@ -49,6 +49,8 @@ ZEND_END_ARG_INFO() #define arginfo_class_Async_Scope_isClosed arginfo_class_Async_Scope_isFinished +#define arginfo_class_Async_Scope_isCancelled arginfo_class_Async_Scope_isFinished + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scope_setExceptionHandler, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, exceptionHandler, IS_CALLABLE, 0) ZEND_END_ARG_INFO() @@ -81,6 +83,7 @@ ZEND_METHOD(Async_Scope, awaitCompletion); ZEND_METHOD(Async_Scope, awaitAfterCancellation); ZEND_METHOD(Async_Scope, isFinished); ZEND_METHOD(Async_Scope, isClosed); +ZEND_METHOD(Async_Scope, isCancelled); ZEND_METHOD(Async_Scope, setExceptionHandler); ZEND_METHOD(Async_Scope, setChildScopeExceptionHandler); ZEND_METHOD(Async_Scope, onFinally); @@ -111,6 +114,7 @@ static const zend_function_entry class_Async_Scope_methods[] = { ZEND_ME(Async_Scope, awaitAfterCancellation, arginfo_class_Async_Scope_awaitAfterCancellation, ZEND_ACC_PUBLIC) ZEND_ME(Async_Scope, isFinished, arginfo_class_Async_Scope_isFinished, ZEND_ACC_PUBLIC) ZEND_ME(Async_Scope, isClosed, arginfo_class_Async_Scope_isClosed, ZEND_ACC_PUBLIC) + ZEND_ME(Async_Scope, isCancelled, arginfo_class_Async_Scope_isCancelled, ZEND_ACC_PUBLIC) ZEND_ME(Async_Scope, setExceptionHandler, arginfo_class_Async_Scope_setExceptionHandler, ZEND_ACC_PUBLIC) ZEND_ME(Async_Scope, setChildScopeExceptionHandler, arginfo_class_Async_Scope_setChildScopeExceptionHandler, ZEND_ACC_PUBLIC) ZEND_ME(Async_Scope, onFinally, arginfo_class_Async_Scope_onFinally, ZEND_ACC_PUBLIC) diff --git a/tests/await/005-awaitAny_basic.phpt b/tests/await/005-awaitAny_basic.phpt index 2d9bd32..06bd515 100644 --- a/tests/await/005-awaitAny_basic.phpt +++ b/tests/await/005-awaitAny_basic.phpt @@ -6,20 +6,20 @@ awaitAny() - basic usage with multiple coroutines use function Async\spawn; use function Async\awaitAny; use function Async\delay; +use function Async\suspend; echo "start\n"; $coroutines = [ spawn(function() { - delay(50); + suspend(); return "first"; }), spawn(function() { - delay(20); return "second"; }), spawn(function() { - delay(100); + suspend(); return "third"; }), ]; diff --git a/tests/await/007-awaitAny_exception.phpt b/tests/await/007-awaitAny_exception.phpt index fc279df..d969d36 100644 --- a/tests/await/007-awaitAny_exception.phpt +++ b/tests/await/007-awaitAny_exception.phpt @@ -5,18 +5,16 @@ awaitAny() - coroutine throws exception use function Async\spawn; use function Async\awaitAny; -use function Async\delay; use function Async\suspend; echo "start\n"; $coroutines = [ spawn(function() { - delay(50); + suspend(); return "first"; }), spawn(function() { - suspend(); throw new RuntimeException("test exception"); }), ]; diff --git a/tests/await/008-awaitFirstSuccess_basic.phpt b/tests/await/008-awaitFirstSuccess_basic.phpt index cbcb032..fd203f6 100644 --- a/tests/await/008-awaitFirstSuccess_basic.phpt +++ b/tests/await/008-awaitFirstSuccess_basic.phpt @@ -5,21 +5,20 @@ awaitFirstSuccess() - basic usage with mixed success and error use function Async\spawn; use function Async\awaitFirstSuccess; -use function Async\delay; +use function Async\suspend; echo "start\n"; $coroutines = [ spawn(function() { - delay(10); + suspend(); throw new RuntimeException("first error"); }), spawn(function() { - delay(20); return "success"; }), spawn(function() { - delay(30); + suspend(); return "another success"; }), ]; diff --git a/tests/await/009-awaitFirstSuccess_all_errors.phpt b/tests/await/009-awaitFirstSuccess_all_errors.phpt index 15b8af2..8ed6a56 100644 --- a/tests/await/009-awaitFirstSuccess_all_errors.phpt +++ b/tests/await/009-awaitFirstSuccess_all_errors.phpt @@ -5,17 +5,14 @@ awaitFirstSuccess() - all coroutines throw exceptions use function Async\spawn; use function Async\awaitFirstSuccess; -use function Async\delay; echo "start\n"; $coroutines = [ spawn(function() { - delay(20); throw new RuntimeException("first error"); }), spawn(function() { - delay(30); throw new RuntimeException("second error"); }), ]; diff --git a/tests/await/010-awaitAll_basic.phpt b/tests/await/010-awaitAll_basic.phpt index 06e9777..2fb887c 100644 --- a/tests/await/010-awaitAll_basic.phpt +++ b/tests/await/010-awaitAll_basic.phpt @@ -5,21 +5,17 @@ awaitAll() - basic usage with multiple coroutines use function Async\spawn; use function Async\awaitAll; -use function Async\delay; echo "start\n"; $coroutines = [ spawn(function() { - delay(50); return "first"; }), spawn(function() { - delay(20); return "second"; }), spawn(function() { - delay(30); return "third"; }), ]; diff --git a/tests/await/011-awaitAll_exception.phpt b/tests/await/011-awaitAll_exception.phpt index 98f91c9..5ac90ea 100644 --- a/tests/await/011-awaitAll_exception.phpt +++ b/tests/await/011-awaitAll_exception.phpt @@ -5,21 +5,17 @@ awaitAll() - one coroutine throws exception use function Async\spawn; use function Async\awaitAll; -use function Async\delay; echo "start\n"; $coroutines = [ spawn(function() { - delay(50); return "first"; }), spawn(function() { - delay(20); throw new RuntimeException("test exception"); }), spawn(function() { - delay(30); return "third"; }), ]; diff --git a/tests/await/012-awaitAllWithErrors_basic.phpt b/tests/await/012-awaitAllWithErrors_basic.phpt index 8a796aa..3095495 100644 --- a/tests/await/012-awaitAllWithErrors_basic.phpt +++ b/tests/await/012-awaitAllWithErrors_basic.phpt @@ -5,21 +5,17 @@ awaitAllWithErrors() - basic usage with mixed success and error use function Async\spawn; use function Async\awaitAllWithErrors; -use function Async\delay; echo "start\n"; $coroutines = [ spawn(function() { - delay(50); return "first"; }), spawn(function() { - delay(20); throw new RuntimeException("test exception"); }), spawn(function() { - delay(30); return "third"; }), ]; @@ -33,11 +29,9 @@ echo "end\n"; start array(2) { [0]=> - array(3) { + array(2) { [0]=> string(5) "first" - [1]=> - NULL [2]=> string(5) "third" } diff --git a/tests/await/013-awaitAllWithErrors_all_success.phpt b/tests/await/013-awaitAllWithErrors_all_success.phpt index 6cff6ba..3c1a6ef 100644 --- a/tests/await/013-awaitAllWithErrors_all_success.phpt +++ b/tests/await/013-awaitAllWithErrors_all_success.phpt @@ -5,21 +5,17 @@ awaitAllWithErrors() - all coroutines succeed use function Async\spawn; use function Async\awaitAllWithErrors; -use function Async\delay; echo "start\n"; $coroutines = [ spawn(function() { - delay(50); return "first"; }), spawn(function() { - delay(20); return "second"; }), spawn(function() { - delay(30); return "third"; }), ]; diff --git a/tests/await/014-awaitAnyOf_basic.phpt b/tests/await/014-awaitAnyOf_basic.phpt index 10ed78c..e3bb6a8 100644 --- a/tests/await/014-awaitAnyOf_basic.phpt +++ b/tests/await/014-awaitAnyOf_basic.phpt @@ -5,25 +5,20 @@ awaitAnyOf() - basic usage with count parameter use function Async\spawn; use function Async\awaitAnyOf; -use function Async\delay; echo "start\n"; $coroutines = [ spawn(function() { - delay(80); return "first"; }), spawn(function() { - delay(20); return "second"; }), spawn(function() { - delay(60); return "third"; }), spawn(function() { - delay(25); return "fourth"; }), ]; diff --git a/tests/await/015-awaitAnyOf_count_zero.phpt b/tests/await/015-awaitAnyOf_count_zero.phpt index 65b57f0..ffec806 100644 --- a/tests/await/015-awaitAnyOf_count_zero.phpt +++ b/tests/await/015-awaitAnyOf_count_zero.phpt @@ -5,17 +5,14 @@ awaitAnyOf() - count is zero use function Async\spawn; use function Async\awaitAnyOf; -use function Async\delay; echo "start\n"; $coroutines = [ spawn(function() { - delay(20); return "first"; }), spawn(function() { - delay(30); return "second"; }), ]; diff --git a/tests/await/016-awaitAnyOfWithErrors_basic.phpt b/tests/await/016-awaitAnyOfWithErrors_basic.phpt index ea9815a..55ebb8b 100644 --- a/tests/await/016-awaitAnyOfWithErrors_basic.phpt +++ b/tests/await/016-awaitAnyOfWithErrors_basic.phpt @@ -5,25 +5,22 @@ awaitAnyOfWithErrors() - basic usage with mixed success and error use function Async\spawn; use function Async\awaitAnyOfWithErrors; -use function Async\delay; +use function Async\suspend; echo "start\n"; $coroutines = [ spawn(function() { - delay(80); return "first"; }), spawn(function() { - delay(20); + suspend(); throw new RuntimeException("test exception"); }), spawn(function() { - delay(20); return "third"; }), spawn(function() { - delay(35); return "fourth"; }), ]; diff --git a/tests/await/017-awaitAnyOfWithErrors_all_success.phpt b/tests/await/017-awaitAnyOfWithErrors_all_success.phpt index f10a10f..d9869e9 100644 --- a/tests/await/017-awaitAnyOfWithErrors_all_success.phpt +++ b/tests/await/017-awaitAnyOfWithErrors_all_success.phpt @@ -5,21 +5,17 @@ awaitAnyOfWithErrors() - all coroutines succeed use function Async\spawn; use function Async\awaitAnyOfWithErrors; -use function Async\delay; echo "start\n"; $coroutines = [ spawn(function() { - delay(50); return "first"; }), spawn(function() { - delay(20); return "second"; }), spawn(function() { - delay(30); return "third"; }), ]; diff --git a/tests/await/018-awaitAll_double_free.phpt b/tests/await/018-awaitAll_double_free.phpt index 751bd63..3b06e67 100644 --- a/tests/await/018-awaitAll_double_free.phpt +++ b/tests/await/018-awaitAll_double_free.phpt @@ -5,16 +5,14 @@ awaitAll() - test for double free issue with many coroutines use function Async\spawn; use function Async\awaitAll; -use function Async\delay; echo "start\n"; $coroutines = []; -// create multiple coroutines that will return values after a delay +// create multiple coroutines that will return values for ($i = 1; $i <= 100; $i++) { $coroutines[] = spawn(function() use ($i) { - delay($i); return "coroutine $i"; }); } diff --git a/tests/await/019-awaitAll_iterator.phpt b/tests/await/019-awaitAll_iterator.phpt new file mode 100644 index 0000000..bf292d4 --- /dev/null +++ b/tests/await/019-awaitAll_iterator.phpt @@ -0,0 +1,65 @@ +--TEST-- +awaitAll() - with Iterator +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // We create a coroutine inside the iteration because + // this is the only way to ensure it will definitely be captured by await. + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +// Note that we cannot create coroutines before the iterator runs, +// because in that case the coroutines would start earlier, +// and the await expression wouldn't have a chance to capture them. +$functions = [ + fn() => "first", + fn() => "second", + fn() => "third", +]; + +$iterator = new TestIterator($functions); +$results = awaitAll($iterator); + +$countOfResults = count($results) == 3 ? "OK" : "FALSE: ".count($results); +echo "Count of results: $countOfResults\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Count of results: OK +end \ No newline at end of file diff --git a/tests/await/020-awaitAny_iterator.phpt b/tests/await/020-awaitAny_iterator.phpt new file mode 100644 index 0000000..e6e5692 --- /dev/null +++ b/tests/await/020-awaitAny_iterator.phpt @@ -0,0 +1,71 @@ +--TEST-- +awaitAny() - with Iterator +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // We create a coroutine inside the iteration because + // this is the only way to ensure it will definitely be captured by await. + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +// Note that we cannot create coroutines before the iterator runs, +// because in that case the coroutines would start earlier, +// and the await expression wouldn't have a chance to capture them. +$functions = [ + function() { + suspend(); + return "slow"; + }, + function() { + return "fast"; + }, + function() { + return "medium"; + }, +]; + +$iterator = new TestIterator($functions); +$result = awaitAny($iterator); + +echo "Result: $result\n"; +echo "end\n"; + +?> +--EXPECT-- +start +Result: fast +end \ No newline at end of file diff --git a/tests/await/021-awaitFirstSuccess_iterator.phpt b/tests/await/021-awaitFirstSuccess_iterator.phpt new file mode 100644 index 0000000..5b522a3 --- /dev/null +++ b/tests/await/021-awaitFirstSuccess_iterator.phpt @@ -0,0 +1,63 @@ +--TEST-- +awaitFirstSuccess() - with Iterator +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // We create a coroutine inside the iteration because + // this is the only way to ensure it will definitely be captured by await. + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +// Note that we cannot create coroutines before the iterator runs, +// because in that case the coroutines would start earlier, +// and the await expression wouldn't have a chance to capture them. +$functions = [ + fn() => throw new RuntimeException("error"), + fn() => "success", + fn() => "another success", +]; + +$iterator = new TestIterator($functions); +$result = awaitFirstSuccess($iterator); + +echo "Result: {$result[0]}\n"; +echo "end\n"; + +?> +--EXPECT-- +start +Result: success +end \ No newline at end of file diff --git a/tests/await/022-awaitAllWithErrors_iterator.phpt b/tests/await/022-awaitAllWithErrors_iterator.phpt new file mode 100644 index 0000000..5ac740a --- /dev/null +++ b/tests/await/022-awaitAllWithErrors_iterator.phpt @@ -0,0 +1,68 @@ +--TEST-- +awaitAllWithErrors() - with Iterator +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // We create a coroutine inside the iteration because + // this is the only way to ensure it will definitely be captured by await. + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +// Note that we cannot create coroutines before the iterator runs, +// because in that case the coroutines would start earlier, +// and the await expression wouldn’t have a chance to capture them. +$functions = [ + fn() => "success", + fn() => throw new RuntimeException("error"), + fn() => "another success", +]; + +$iterator = new TestIterator($functions); +$result = awaitAllWithErrors($iterator); + +$countOfResults = count($result[0]) == 2 ? "OK" : "FALSE: ".count($result[0]); +$countOfErrors = count($result[1]) == 1 ? "OK" : "FALSE: ".count($result[1]); + +echo "Count of results: $countOfResults\n"; +echo "Count of errors: $countOfErrors\n"; +echo "end\n"; + +?> +--EXPECT-- +start +Count of results: OK +Count of errors: OK +end \ No newline at end of file diff --git a/tests/await/023-awaitAnyOf_iterator.phpt b/tests/await/023-awaitAnyOf_iterator.phpt new file mode 100644 index 0000000..7cd0be8 --- /dev/null +++ b/tests/await/023-awaitAnyOf_iterator.phpt @@ -0,0 +1,61 @@ +--TEST-- +awaitAnyOf() - with Iterator +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$coroutines = [ + fn() => "first", + fn() => "second", + fn() => "third", + fn() => "fourth", +]; + +$iterator = new TestIterator($coroutines); +$results = awaitAnyOf(2, $iterator); + +$countOfResults = count($results) >= 2 ? "OK" : "FALSE: ".count($results); +echo "Count of results: $countOfResults\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Count of results: OK +end \ No newline at end of file diff --git a/tests/await/024-awaitAnyOfWithErrors_iterator.phpt b/tests/await/024-awaitAnyOfWithErrors_iterator.phpt new file mode 100644 index 0000000..80d19c3 --- /dev/null +++ b/tests/await/024-awaitAnyOfWithErrors_iterator.phpt @@ -0,0 +1,65 @@ +--TEST-- +awaitAnyOfWithErrors() - with Iterator +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$coroutines = [ + fn() => "first", + fn() => throw new RuntimeException("error"), + fn() => "third", + fn() => "fourth", +]; + +$iterator = new TestIterator($coroutines); +$result = awaitAnyOfWithErrors(2, $iterator); + +$countOfResults = count($result[0]) >= 2 ? "OK" : "FALSE: ".count($result[0]); +$countOfErrors = count($result[1]) == 1 ? "OK" : "FALSE: ".count($result[1]); + +echo "Count of results: $countOfResults\n"; +echo "Count of errors: $countOfErrors\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Count of results: OK +Count of errors: OK +end \ No newline at end of file diff --git a/tests/await/025-awaitAll_generator.phpt b/tests/await/025-awaitAll_generator.phpt new file mode 100644 index 0000000..3d82832 --- /dev/null +++ b/tests/await/025-awaitAll_generator.phpt @@ -0,0 +1,38 @@ +--TEST-- +awaitAll() - with Generator +--FILE-- + +--EXPECT-- +start +Count of results: OK +end \ No newline at end of file diff --git a/tests/await/026-awaitAny_generator.phpt b/tests/await/026-awaitAny_generator.phpt new file mode 100644 index 0000000..03981ae --- /dev/null +++ b/tests/await/026-awaitAny_generator.phpt @@ -0,0 +1,38 @@ +--TEST-- +awaitAny() - with Generator +--FILE-- + +--EXPECT-- +start +Result: fast +end \ No newline at end of file diff --git a/tests/await/027-awaitFirstSuccess_generator.phpt b/tests/await/027-awaitFirstSuccess_generator.phpt new file mode 100644 index 0000000..7659c03 --- /dev/null +++ b/tests/await/027-awaitFirstSuccess_generator.phpt @@ -0,0 +1,36 @@ +--TEST-- +awaitFirstSuccess() - with Generator +--FILE-- + +--EXPECT-- +start +Result: success +end \ No newline at end of file diff --git a/tests/await/028-awaitAllWithErrors_generator.phpt b/tests/await/028-awaitAllWithErrors_generator.phpt new file mode 100644 index 0000000..aaa2607 --- /dev/null +++ b/tests/await/028-awaitAllWithErrors_generator.phpt @@ -0,0 +1,41 @@ +--TEST-- +awaitAllWithErrors() - with Generator +--FILE-- + +--EXPECT-- +start +Count of results: OK +Count of errors: OK +end \ No newline at end of file diff --git a/tests/await/029-awaitAnyOf_generator.phpt b/tests/await/029-awaitAnyOf_generator.phpt new file mode 100644 index 0000000..8abdba7 --- /dev/null +++ b/tests/await/029-awaitAnyOf_generator.phpt @@ -0,0 +1,42 @@ +--TEST-- +awaitAnyOf() - with Generator +--FILE-- += 2 ? "OK" : "FALSE: ".count($results); +echo "Count of results: $countOfResults\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Count of results: OK +end \ No newline at end of file diff --git a/tests/await/030-awaitAnyOfWithErrors_generator.phpt b/tests/await/030-awaitAnyOfWithErrors_generator.phpt new file mode 100644 index 0000000..71b7654 --- /dev/null +++ b/tests/await/030-awaitAnyOfWithErrors_generator.phpt @@ -0,0 +1,47 @@ +--TEST-- +awaitAnyOfWithErrors() - with Generator +--FILE-- += 2 ? "OK" : "FALSE: ".count($result[0]); +$countOfErrors = count($result[1]) == 1 ? "OK" : "FALSE: ".count($result[1]); + +echo "Count of results: $countOfResults\n"; +echo "Count of errors: $countOfErrors\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Count of results: OK +Count of errors: OK +end \ No newline at end of file diff --git a/tests/await/031-awaitAll_with_interruption.phpt b/tests/await/031-awaitAll_with_interruption.phpt new file mode 100644 index 0000000..a65415d --- /dev/null +++ b/tests/await/031-awaitAll_with_interruption.phpt @@ -0,0 +1,71 @@ +--TEST-- +awaitAll() - With an unexpected interruption of execution. +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // We create a coroutine inside the iteration because + // this is the only way to ensure it will definitely be captured by await. + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +// Note that we cannot create coroutines before the iterator runs, +// because in that case the coroutines would start earlier, +// and the await expression wouldn't have a chance to capture them. +$functions = [ + fn() => "first", + fn() => "second", + fn() => "third", +]; + +spawn(fn() => throw new Exception("Unexpected interruption")); + +$iterator = new TestIterator($functions); +$results = awaitAll($iterator); + +$countOfResults = count($results) == 3 ? "OK" : "FALSE: ".count($results); +echo "Count of results: $countOfResults\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start + +Fatal error: Uncaught Exception: Unexpected interruption in %s:%d +Stack trace: +#0 [internal function]: {closure:%s:%d}() +#1 {main} + thrown in %s on line %d \ No newline at end of file diff --git a/tests/await/032-awaitAny_with_interruption.phpt b/tests/await/032-awaitAny_with_interruption.phpt new file mode 100644 index 0000000..428e5fd --- /dev/null +++ b/tests/await/032-awaitAny_with_interruption.phpt @@ -0,0 +1,70 @@ +--TEST-- +awaitAny() - With an unexpected interruption of execution. +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // We create a coroutine inside the iteration because + // this is the only way to ensure it will definitely be captured by await. + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +// Note that we cannot create coroutines before the iterator runs, +// because in that case the coroutines would start earlier, +// and the await expression wouldn't have a chance to capture them. +$functions = [ + function() { suspend(); return "slow"; }, + function() { return "fast"; }, + function() { suspend(); return "medium"; }, +]; + +spawn(fn() => throw new Exception("Unexpected interruption")); + +$iterator = new TestIterator($functions); +$result = awaitAny($iterator); + +echo "Result: $result\n"; +echo "end\n"; + +?> +--EXPECTF-- +start + +Fatal error: Uncaught Exception: Unexpected interruption in %s:%d +Stack trace: +#0 [internal function]: {closure:%s:%d}() +#1 {main} + thrown in %s on line %d \ No newline at end of file diff --git a/tests/await/033-awaitFirstSuccess_with_interruption.phpt b/tests/await/033-awaitFirstSuccess_with_interruption.phpt new file mode 100644 index 0000000..11e41de --- /dev/null +++ b/tests/await/033-awaitFirstSuccess_with_interruption.phpt @@ -0,0 +1,69 @@ +--TEST-- +awaitFirstSuccess() - With an unexpected interruption of execution. +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // We create a coroutine inside the iteration because + // this is the only way to ensure it will definitely be captured by await. + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +// Note that we cannot create coroutines before the iterator runs, +// because in that case the coroutines would start earlier, +// and the await expression wouldn't have a chance to capture them. +$functions = [ + function() { throw new RuntimeException("error"); }, + function() { return "success"; }, + function() { return "another success"; }, +]; + +spawn(fn() => throw new Exception("Unexpected interruption")); + +$iterator = new TestIterator($functions); +$result = awaitFirstSuccess($iterator); + +echo "Result: {$result[0]}\n"; +echo "end\n"; + +?> +--EXPECTF-- +start + +Fatal error: Uncaught Exception: Unexpected interruption in %s:%d +Stack trace: +#0 [internal function]: {closure:%s:%d}() +#1 {main} + thrown in %s on line %d \ No newline at end of file diff --git a/tests/await/034-awaitAll_preserve_key_order.phpt b/tests/await/034-awaitAll_preserve_key_order.phpt new file mode 100644 index 0000000..4331b19 --- /dev/null +++ b/tests/await/034-awaitAll_preserve_key_order.phpt @@ -0,0 +1,50 @@ +--TEST-- +awaitAll() - with fillNull parameter +--FILE-- + +--EXPECT-- +start +All expected results found +end \ No newline at end of file diff --git a/tests/await/035-awaitAllWithErrors_fillNull.phpt b/tests/await/035-awaitAllWithErrors_fillNull.phpt new file mode 100644 index 0000000..ead79b5 --- /dev/null +++ b/tests/await/035-awaitAllWithErrors_fillNull.phpt @@ -0,0 +1,47 @@ +--TEST-- +awaitAllWithErrors() - with fillNull parameter +--FILE-- +getMessage() . "\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Count of results: 3 +Count of errors: 1 +Result 0: success +Result 1: null +Result 2: another success +Error message: error +end \ No newline at end of file diff --git a/tests/await/036-awaitAll_cancellation_timeout.phpt b/tests/await/036-awaitAll_cancellation_timeout.phpt new file mode 100644 index 0000000..aae91b7 --- /dev/null +++ b/tests/await/036-awaitAll_cancellation_timeout.phpt @@ -0,0 +1,45 @@ +--TEST-- +awaitAll() - with cancellation timeout +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Timeout caught as expected +end \ No newline at end of file diff --git a/tests/await/037-awaitAny_cancellation_timeout.phpt b/tests/await/037-awaitAny_cancellation_timeout.phpt new file mode 100644 index 0000000..6228834 --- /dev/null +++ b/tests/await/037-awaitAny_cancellation_timeout.phpt @@ -0,0 +1,41 @@ +--TEST-- +awaitAny() - with cancellation timeout +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Timeout caught as expected +end \ No newline at end of file diff --git a/tests/await/038-awaitFirstSuccess_cancellation_timeout.phpt b/tests/await/038-awaitFirstSuccess_cancellation_timeout.phpt new file mode 100644 index 0000000..489e6f5 --- /dev/null +++ b/tests/await/038-awaitFirstSuccess_cancellation_timeout.phpt @@ -0,0 +1,40 @@ +--TEST-- +awaitFirstSuccess() - with cancellation timeout +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Timeout caught as expected +end \ No newline at end of file diff --git a/tests/await/039-awaitAnyOf_cancellation_timeout.phpt b/tests/await/039-awaitAnyOf_cancellation_timeout.phpt new file mode 100644 index 0000000..ff76e07 --- /dev/null +++ b/tests/await/039-awaitAnyOf_cancellation_timeout.phpt @@ -0,0 +1,46 @@ +--TEST-- +awaitAnyOf() - with cancellation timeout +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Timeout caught as expected +end \ No newline at end of file diff --git a/tests/await/040-await_cancellation_timeout.phpt b/tests/await/040-await_cancellation_timeout.phpt new file mode 100644 index 0000000..aeec81f --- /dev/null +++ b/tests/await/040-await_cancellation_timeout.phpt @@ -0,0 +1,33 @@ +--TEST-- +await() - with cancellation timeout +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Timeout caught as expected +end \ No newline at end of file diff --git a/tests/await/041-awaitAll_associative_array.phpt b/tests/await/041-awaitAll_associative_array.phpt new file mode 100644 index 0000000..cfbb11b --- /dev/null +++ b/tests/await/041-awaitAll_associative_array.phpt @@ -0,0 +1,43 @@ +--TEST-- +awaitAll() - with associative array +--FILE-- + spawn(function() { + return "first"; + }), + + 'task2' => spawn(function() { + return "second"; + }), + + 'task3' => spawn(function() { + return "third"; + }) +]; + +echo "start\n"; + +$results = awaitAll($coroutines); + +echo "Count: " . count($results) . "\n"; +echo "Keys preserved: " . (array_keys($results) === ['task1', 'task2', 'task3'] ? "YES" : "NO") . "\n"; +echo "Result task1: {$results['task1']}\n"; +echo "Result task2: {$results['task2']}\n"; +echo "Result task3: {$results['task3']}\n"; +echo "end\n"; + +?> +--EXPECT-- +start +Count: 3 +Keys preserved: YES +Result task1: first +Result task2: second +Result task3: third +end \ No newline at end of file diff --git a/tests/await/042-awaitAllWithErrors_associative_array.phpt b/tests/await/042-awaitAllWithErrors_associative_array.phpt new file mode 100644 index 0000000..05b37da --- /dev/null +++ b/tests/await/042-awaitAllWithErrors_associative_array.phpt @@ -0,0 +1,47 @@ +--TEST-- +awaitAllWithErrors() - with associative array +--FILE-- + spawn(function() { + return "first success"; + }), + + 'error1' => spawn(function() { + throw new RuntimeException("first error"); + }), + + 'success2' => spawn(function() { + return "second success"; + }) +]; + +echo "start\n"; + +$result = awaitAllWithErrors($coroutines); + +echo "Count of results: " . count($result[0]) . "\n"; +echo "Count of errors: " . count($result[1]) . "\n"; +echo "Result keys: " . implode(', ', array_keys($result[0])) . "\n"; +echo "Error keys: " . implode(', ', array_keys($result[1])) . "\n"; +echo "Result success1: {$result[0]['success1']}\n"; +echo "Result success2: {$result[0]['success2']}\n"; +echo "Error error1: {$result[1]['error1']->getMessage()}\n"; +echo "end\n"; + +?> +--EXPECT-- +start +Count of results: 2 +Count of errors: 1 +Result keys: success1, success2 +Error keys: error1 +Result success1: first success +Result success2: second success +Error error1: first error +end \ No newline at end of file diff --git a/tests/await/043-awaitAnyOf_associative_array.phpt b/tests/await/043-awaitAnyOf_associative_array.phpt new file mode 100644 index 0000000..ee761c2 --- /dev/null +++ b/tests/await/043-awaitAnyOf_associative_array.phpt @@ -0,0 +1,50 @@ +--TEST-- +awaitAnyOf() - with associative array +--FILE-- + spawn(function() { + suspend(); + return "slow task"; + }), + + 'fast' => spawn(function() { + return "fast task"; + }), + + 'medium' => spawn(function() { + return "medium task"; + }), + + 'very_slow' => spawn(function() { + suspend(); + return "very slow task"; + }) +]; + +echo "start\n"; + +$results = awaitAnyOf(2, $coroutines); + +echo "Keys preserved: " . (count(array_intersect(array_keys($results), ['slow', 'fast', 'medium', 'very_slow'])) == count($results) ? "YES" : "NO") . "\n"; + +// The fastest should complete first +$keys = array_keys($results); +echo "First completed key: {$keys[0]}\n"; +echo "First completed value: {$results[$keys[0]]}\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Keys preserved: YES +First completed key: slow +First completed value: slow task +end \ No newline at end of file diff --git a/tests/await/044-awaitAll_empty_iterable.phpt b/tests/await/044-awaitAll_empty_iterable.phpt new file mode 100644 index 0000000..7887fa0 --- /dev/null +++ b/tests/await/044-awaitAll_empty_iterable.phpt @@ -0,0 +1,47 @@ +--TEST-- +awaitAll() - with empty iterable +--FILE-- + +--EXPECT-- +start +Empty array count: 0 +Empty array type: array +Empty ArrayObject count: 0 +Empty ArrayObject type: array +Empty generator count: 0 +Empty generator type: array +end \ No newline at end of file diff --git a/tests/await/045-awaitAnyOf_edge_cases.phpt b/tests/await/045-awaitAnyOf_edge_cases.phpt new file mode 100644 index 0000000..c3cb50f --- /dev/null +++ b/tests/await/045-awaitAnyOf_edge_cases.phpt @@ -0,0 +1,45 @@ +--TEST-- +awaitAnyOf() - edge cases with count parameter +--FILE-- + +--EXPECT-- +start +Count when requesting more than available: 2 +Count when requesting zero: 0 +end \ No newline at end of file diff --git a/tests/await/046-awaitFirstSuccess_all_errors.phpt b/tests/await/046-awaitFirstSuccess_all_errors.phpt new file mode 100644 index 0000000..e707c73 --- /dev/null +++ b/tests/await/046-awaitFirstSuccess_all_errors.phpt @@ -0,0 +1,37 @@ +--TEST-- +awaitFirstSuccess() - when all coroutines throw errors +--FILE-- + +--EXPECT-- +start +Result: NULL +Errors count: 3 +end \ No newline at end of file diff --git a/tests/await/047-awaitAll_concurrent_iterator.phpt b/tests/await/047-awaitAll_concurrent_iterator.phpt new file mode 100644 index 0000000..b4e10f0 --- /dev/null +++ b/tests/await/047-awaitAll_concurrent_iterator.phpt @@ -0,0 +1,76 @@ +--TEST-- +awaitAll() - With concurrent iterator using suspend() in current() +--FILE-- +items = $items; + } + + public function rewind(): void { + suspend(); // Simulate concurrent access + $this->position = 0; + } + + public function current(): mixed { + // Create coroutine after suspension + echo "Current item: {$this->items[$this->position]}\n"; + return spawn(fn() => $this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + suspend(); // Simulate concurrent access + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$values = ["first", "second", "third"]; +$iterator = new ConcurrentIterator($values); + +spawn(function() { + // Simulate some processing + for ($i = 1; $i <= 5; $i++) { + echo "Processing item $i\n"; + suspend(); + } +}); + +$results = awaitAll($iterator); + +echo "Results: " . implode(", ", $results) . "\n"; +echo "Count: " . count($results) . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +Processing item 1 +Processing item 2 +Current item: first +Processing item 3 +Current item: second +Processing item 4 +Current item: third +Processing item 5 +Results: first, second, third +Count: 3 +end \ No newline at end of file diff --git a/tests/await/048-awaitAll_concurrent_generator.phpt b/tests/await/048-awaitAll_concurrent_generator.phpt new file mode 100644 index 0000000..4cc8a58 --- /dev/null +++ b/tests/await/048-awaitAll_concurrent_generator.phpt @@ -0,0 +1,51 @@ +--TEST-- +awaitAll() - With concurrent generator using suspend() in body +--FILE-- + $value); + } +} + +echo "start\n"; + +$values = ["first", "second", "third"]; +$generator = concurrentGenerator($values); + +spawn(function() { + // Simulate some processing + for ($i = 1; $i <= 5; $i++) { + echo "Processing item $i\n"; + suspend(); + } +}); + +$results = awaitAll($generator); + +echo "Results: " . implode(", ", $results) . "\n"; +echo "Count: " . count($results) . "\n"; +echo "end\n"; + +?> +--EXPECT-- +start +Processing item 1 +Processing item 2 +Yielding item: first +Processing item 3 +Yielding item: second +Processing item 4 +Yielding item: third +Processing item 5 +Results: first, second, third +Count: 3 +end \ No newline at end of file diff --git a/tests/await/049-awaitAny_concurrent_iterator.phpt b/tests/await/049-awaitAny_concurrent_iterator.phpt new file mode 100644 index 0000000..40f8eda --- /dev/null +++ b/tests/await/049-awaitAny_concurrent_iterator.phpt @@ -0,0 +1,76 @@ +--TEST-- +awaitAny() - With concurrent iterator using suspend() in current() +--FILE-- +items = $items; + } + + public function rewind(): void { + suspend(); // Simulate concurrent access + $this->position = 0; + } + + public function current(): mixed { + echo "Current item at position: {$this->position}\n"; + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + suspend(); // Simulate concurrent access + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$functions = [ + function() { suspend(); suspend(); return "slow"; }, + function() { return "fast"; }, + function() { suspend(); return "medium"; }, +]; + +$iterator = new ConcurrentIterator($functions); + +spawn(function() { + // Simulate some processing + for ($i = 1; $i <= 3; $i++) { + echo "Processing item $i\n"; + suspend(); + } +}); + +$result = awaitAny($iterator); + +echo "Result: $result\n"; +echo "end\n"; + +?> +--EXPECT-- +start +Processing item 1 +Processing item 2 +Current item at position: 0 +Processing item 3 +Current item at position: 1 +Current item at position: 2 +Result: fast +end \ No newline at end of file diff --git a/tests/await/050-awaitFirstSuccess_concurrent_generator.phpt b/tests/await/050-awaitFirstSuccess_concurrent_generator.phpt new file mode 100644 index 0000000..0d54deb --- /dev/null +++ b/tests/await/050-awaitFirstSuccess_concurrent_generator.phpt @@ -0,0 +1,37 @@ +--TEST-- +awaitFirstSuccess() - With concurrent generator using suspend() in body +--FILE-- + +--EXPECT-- +start +Result: success +end \ No newline at end of file diff --git a/tests/await/051-awaitAll_iterator_exception.phpt b/tests/await/051-awaitAll_iterator_exception.phpt new file mode 100644 index 0000000..d9ef69a --- /dev/null +++ b/tests/await/051-awaitAll_iterator_exception.phpt @@ -0,0 +1,63 @@ +--TEST-- +awaitAll() - Exception in iterator current() should stop process immediately +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // Throw exception on second iteration + if ($this->position === 1) { + throw new RuntimeException("Iterator exception during iteration"); + } + + return spawn(fn() => $this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$values = ["first", "second", "third"]; +$iterator = new ExceptionIterator($values); + +try { + $results = awaitAll($iterator); + echo "This should not be reached\n"; +} catch (RuntimeException $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught exception: Iterator exception during iteration +end \ No newline at end of file diff --git a/tests/await/052-awaitAny_iterator_exception.phpt b/tests/await/052-awaitAny_iterator_exception.phpt new file mode 100644 index 0000000..f67e1ad --- /dev/null +++ b/tests/await/052-awaitAny_iterator_exception.phpt @@ -0,0 +1,67 @@ +--TEST-- +awaitAny() - Exception in iterator current() should stop process immediately +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // Throw exception on first iteration + if ($this->position === 0) { + throw new RuntimeException("Iterator exception during iteration"); + } + + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$functions = [ + function() { return "fast"; }, + function() { suspend(); return "slow"; }, +]; + +$iterator = new ExceptionIterator($functions); + +try { + $result = awaitAny($iterator); + echo "This should not be reached\n"; +} catch (RuntimeException $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught exception: Iterator exception during iteration +end \ No newline at end of file diff --git a/tests/await/053-awaitFirstSuccess_iterator_exception.phpt b/tests/await/053-awaitFirstSuccess_iterator_exception.phpt new file mode 100644 index 0000000..f18bfbf --- /dev/null +++ b/tests/await/053-awaitFirstSuccess_iterator_exception.phpt @@ -0,0 +1,68 @@ +--TEST-- +awaitFirstSuccess() - Exception in iterator current() should stop process immediately +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + // Throw exception on second iteration + if ($this->position === 1) { + throw new RuntimeException("Iterator exception during iteration"); + } + + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$functions = [ + function() { throw new RuntimeException("coroutine error"); }, + function() { return "success"; }, + function() { return "another success"; }, +]; + +$iterator = new ExceptionIterator($functions); + +try { + $result = awaitFirstSuccess($iterator); + echo "This should not be reached\n"; +} catch (RuntimeException $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught exception: Iterator exception during iteration +end \ No newline at end of file diff --git a/tests/await/054-awaitAll_generator_exception.phpt b/tests/await/054-awaitAll_generator_exception.phpt new file mode 100644 index 0000000..d62eb2e --- /dev/null +++ b/tests/await/054-awaitAll_generator_exception.phpt @@ -0,0 +1,41 @@ +--TEST-- +awaitAll() - Exception in generator body should stop process immediately +--FILE-- + $value); + $count++; + } +} + +echo "start\n"; + +$values = ["first", "second", "third"]; +$generator = exceptionGenerator($values); + +try { + $results = awaitAll($generator); + echo "This should not be reached\n"; +} catch (RuntimeException $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught exception: Generator exception during iteration +end \ No newline at end of file diff --git a/tests/await/055-awaitAny_generator_exception.phpt b/tests/await/055-awaitAny_generator_exception.phpt new file mode 100644 index 0000000..9b0ae1e --- /dev/null +++ b/tests/await/055-awaitAny_generator_exception.phpt @@ -0,0 +1,45 @@ +--TEST-- +awaitAny() - Exception in generator body should stop process immediately +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught exception: Generator exception during iteration +end \ No newline at end of file diff --git a/tests/await/056-awaitFirstSuccess_generator_exception.phpt b/tests/await/056-awaitFirstSuccess_generator_exception.phpt new file mode 100644 index 0000000..4927929 --- /dev/null +++ b/tests/await/056-awaitFirstSuccess_generator_exception.phpt @@ -0,0 +1,46 @@ +--TEST-- +awaitFirstSuccess() - Exception in generator body should stop process immediately +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught exception: Generator exception during iteration +end \ No newline at end of file diff --git a/tests/await/057-awaitAll_iterator_next_exception.phpt b/tests/await/057-awaitAll_iterator_next_exception.phpt new file mode 100644 index 0000000..20c043f --- /dev/null +++ b/tests/await/057-awaitAll_iterator_next_exception.phpt @@ -0,0 +1,61 @@ +--TEST-- +awaitAll() - Exception in iterator next() should stop process immediately +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + return spawn(fn() => $this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + // Throw exception when moving to next position + if ($this->position === 0) { + throw new RuntimeException("Iterator next() exception"); + } + $this->position++; + } + + public function valid(): bool { + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$values = ["first", "second", "third"]; +$iterator = new ExceptionNextIterator($values); + +try { + $results = awaitAll($iterator); + echo "This should not be reached\n"; +} catch (RuntimeException $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught exception: Iterator next() exception +end \ No newline at end of file diff --git a/tests/await/058-awaitAny_iterator_valid_exception.phpt b/tests/await/058-awaitAny_iterator_valid_exception.phpt new file mode 100644 index 0000000..8476a53 --- /dev/null +++ b/tests/await/058-awaitAny_iterator_valid_exception.phpt @@ -0,0 +1,69 @@ +--TEST-- +awaitAny() - Exception in iterator valid() should stop process immediately +--FILE-- +items = $items; + } + + public function rewind(): void { + $this->position = 0; + $this->validCalls = 0; + } + + public function current(): mixed { + return spawn($this->items[$this->position]); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + $this->validCalls++; + // Throw exception on third call to valid() + if ($this->validCalls === 3) { + throw new RuntimeException("Iterator valid() exception"); + } + return isset($this->items[$this->position]); + } +} + +echo "start\n"; + +$functions = [ + function() { return "fast"; }, + function() { return "medium"; }, + function() { return "slow"; }, +]; + +$iterator = new ExceptionValidIterator($functions); + +try { + $result = awaitAny($iterator); + echo "This should not be reached\n"; +} catch (RuntimeException $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught exception: Iterator valid() exception +end \ No newline at end of file diff --git a/tests/await/059-awaitAll-with-duplicates.phpt b/tests/await/059-awaitAll-with-duplicates.phpt new file mode 100644 index 0000000..71c8982 --- /dev/null +++ b/tests/await/059-awaitAll-with-duplicates.phpt @@ -0,0 +1,62 @@ +--TEST-- +awaitAll() - with duplicates +--FILE-- +position = 0; + $this->coroutine = spawn(function() { + echo "Coroutine started\n"; + return "result"; + }); + } + + public function current(): mixed { + // Always return the same coroutine + return $this->coroutine; + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return $this->position <= $this->maxPos; + } +} + +echo "start\n"; + +$iterator = new MyIterator(3); + +try { + $result = awaitAny($iterator); +} catch (RuntimeException $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; +} + +var_dump($result); + +echo "end\n"; + +?> +--EXPECT-- +start +Coroutine started +string(6) "result" +end \ No newline at end of file diff --git a/tests/await/060-await_empty_iterable_edge_cases.phpt b/tests/await/060-await_empty_iterable_edge_cases.phpt new file mode 100644 index 0000000..1c2a906 --- /dev/null +++ b/tests/await/060-await_empty_iterable_edge_cases.phpt @@ -0,0 +1,43 @@ +--TEST-- +awaitAll() - empty iterators basic functionality +--FILE-- + +--EXPECT-- +start +EmptyIterator count: 0 +EmptyIterator type: array +Empty SplFixedArray count: 0 +CustomEmptyIterator count: 0 +end \ No newline at end of file diff --git a/tests/await/061-await_iterator_exception_during_traversal.phpt b/tests/await/061-await_iterator_exception_during_traversal.phpt new file mode 100644 index 0000000..8f5704d --- /dev/null +++ b/tests/await/061-await_iterator_exception_during_traversal.phpt @@ -0,0 +1,57 @@ +--TEST-- +awaitAll() - iterator exception in next() method +--FILE-- +position = 0; + } + + public function current(): mixed { + return spawn(function() { + return $this->data[$this->position]; + }); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + if ($this->position === 1) { + throw new \RuntimeException("Iterator next() failed"); + } + } + + public function valid(): bool { + return isset($this->data[$this->position]); + } +} + +try { + $results = awaitAll(new FailingNextIterator()); + echo "ERROR: Should have thrown exception\n"; +} catch (\RuntimeException $e) { + echo "Caught next() exception: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "Caught exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught next() exception: Iterator next() failed +end \ No newline at end of file diff --git a/tests/await/062-await_iterator_exception_valid.phpt b/tests/await/062-await_iterator_exception_valid.phpt new file mode 100644 index 0000000..17590be --- /dev/null +++ b/tests/await/062-await_iterator_exception_valid.phpt @@ -0,0 +1,57 @@ +--TEST-- +awaitAll() - iterator exception in valid() method +--FILE-- +position = 0; + } + + public function current(): mixed { + return spawn(function() { + return $this->data[$this->position]; + }); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + if ($this->position === 1) { + throw new \RuntimeException("Iterator valid() failed"); + } + return isset($this->data[$this->position]); + } +} + +try { + $results = awaitAll(new FailingValidIterator()); + echo "ERROR: Should have thrown exception\n"; +} catch (\RuntimeException $e) { + echo "Caught valid() exception: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "Caught exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught valid() exception: Iterator valid() failed +end \ No newline at end of file diff --git a/tests/await/063-await_iterator_exception_current.phpt b/tests/await/063-await_iterator_exception_current.phpt new file mode 100644 index 0000000..22e9ffc --- /dev/null +++ b/tests/await/063-await_iterator_exception_current.phpt @@ -0,0 +1,57 @@ +--TEST-- +awaitAll() - iterator exception in current() method +--FILE-- +position = 0; + } + + public function current(): mixed { + if ($this->position === 1) { + throw new \RuntimeException("Iterator current() failed"); + } + return spawn(function() { + return $this->data[$this->position]; + }); + } + + public function key(): mixed { + return $this->position; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return isset($this->data[$this->position]); + } +} + +try { + $results = awaitAll(new FailingCurrentIterator()); + echo "ERROR: Should have thrown exception\n"; +} catch (\RuntimeException $e) { + echo "Caught current() exception: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "Caught exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught current() exception: Iterator current() failed +end \ No newline at end of file diff --git a/tests/await/064-await_object_key_error.phpt b/tests/await/064-await_object_key_error.phpt new file mode 100644 index 0000000..ec0ecd0 --- /dev/null +++ b/tests/await/064-await_object_key_error.phpt @@ -0,0 +1,59 @@ +--TEST-- +awaitAll() - iterator with object keys error +--FILE-- +keys = [new stdClass(), new stdClass()]; + $this->values = [ + spawn(function() { return "value1"; }), + spawn(function() { return "value2"; }) + ]; + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + return $this->values[$this->position] ?? null; + } + + public function key(): mixed { + return $this->keys[$this->position] ?? null; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return $this->position < count($this->keys); + } +} + +try { + $result = awaitAll(new ObjectKeyIterator()); + echo "ERROR: Should have failed with object keys\n"; +} catch (Async\AsyncException $e) { + echo "Caught object key error: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +Caught object key error: Invalid key type: must be string, long or null +end \ No newline at end of file diff --git a/tests/await/065-await_resource_key_error.phpt b/tests/await/065-await_resource_key_error.phpt new file mode 100644 index 0000000..d78f26c --- /dev/null +++ b/tests/await/065-await_resource_key_error.phpt @@ -0,0 +1,67 @@ +--TEST-- +awaitAll() - iterator with resource keys error +--FILE-- +keys = [fopen('php://memory', 'r'), fopen('php://memory', 'r')]; + $this->values = [ + spawn(function() { return "value1"; }), + spawn(function() { return "value2"; }) + ]; + } + + public function __destruct() { + foreach ($this->keys as $key) { + if (is_resource($key)) { + fclose($key); + } + } + } + + public function rewind(): void { + $this->position = 0; + } + + public function current(): mixed { + return $this->values[$this->position] ?? null; + } + + public function key(): mixed { + return $this->keys[$this->position] ?? null; + } + + public function next(): void { + $this->position++; + } + + public function valid(): bool { + return $this->position < count($this->keys); + } +} + +try { + $result = awaitAll(new ResourceKeyIterator()); + echo "ERROR: Should have failed with resource keys\n"; +} catch (Async\AsyncException $e) { + echo "Caught resource key error: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +Caught resource key error: Invalid key type: must be string, long or null +end \ No newline at end of file diff --git a/tests/await/066-await_cancelled_coroutine.phpt b/tests/await/066-await_cancelled_coroutine.phpt new file mode 100644 index 0000000..c1e9732 --- /dev/null +++ b/tests/await/066-await_cancelled_coroutine.phpt @@ -0,0 +1,64 @@ +--TEST-- +Await operation on explicitly cancelled coroutine +--FILE-- +cancel(new \Async\CancellationException("Manual cancellation")); +echo "coroutine1 cancelled\n"; + +try { + $result1 = await($coroutine1); + echo "await should not succeed\n"; +} catch (\Async\CancellationException $e) { + echo "caught cancellation: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "caught unexpected: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +// Test 2: await on coroutine cancelled during execution +$coroutine2 = spawn(function() { + echo "coroutine2 started\n"; + suspend(); + echo "coroutine2 should not complete\n"; + return "result2"; +}); + +// Let coroutine start +suspend(); + +$coroutine2->cancel(new \Async\CancellationException("Cancelled during execution")); +echo "coroutine2 cancelled during execution\n"; + +try { + $result2 = await($coroutine2); + echo "await should not succeed\n"; +} catch (\Async\CancellationException $e) { + echo "caught cancellation: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "caught unexpected: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +coroutine1 cancelled +caught cancellation: Manual cancellation +coroutine2 started +coroutine2 cancelled during execution +caught cancellation: Cancelled during execution +end \ No newline at end of file diff --git a/tests/await/067-await_completed_coroutine.phpt b/tests/await/067-await_completed_coroutine.phpt new file mode 100644 index 0000000..b68d0b2 --- /dev/null +++ b/tests/await/067-await_completed_coroutine.phpt @@ -0,0 +1,77 @@ +--TEST-- +Await operation on already completed coroutine +--FILE-- +isFinished() ? "true" : "false") . "\n"; + +$result1 = await($coroutine1); +echo "await result: $result1\n"; + +// Test 2: await on coroutine that completed with exception +$coroutine2 = spawn(function() { + echo "coroutine2 executing\n"; + throw new \RuntimeException("Coroutine error"); +}); + +// Wait for completion +try { + await($coroutine2); // This will suspend until the coroutine is done +} catch (Throwable $e) { + // Catch any exceptions during suspend + echo "suspend error: " . $e->getMessage() . "\n"; +} + +echo "coroutine2 finished: " . ($coroutine2->isFinished() ? "true" : "false") . "\n"; + +try { + $result2 = await($coroutine2); + echo "await should not succeed\n"; +} catch (\RuntimeException $e) { + echo "caught exception: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "caught unexpected: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +// Test 3: await on coroutine returning null +$coroutine3 = spawn(function() { + echo "coroutine3 executing\n"; + return null; +}); + +// Wait for completion +suspend(); + +$result3 = await($coroutine3); +echo "await null result: " . (is_null($result3) ? "null" : $result3) . "\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +coroutine1 executing +coroutine1 finished: true +await result: success_result +coroutine2 executing +suspend error: Coroutine error +coroutine2 finished: true +caught exception: Coroutine error +coroutine3 executing +await null result: null +end \ No newline at end of file diff --git a/tests/await/068-await_multiple_times.phpt b/tests/await/068-await_multiple_times.phpt new file mode 100644 index 0000000..c8445d2 --- /dev/null +++ b/tests/await/068-await_multiple_times.phpt @@ -0,0 +1,100 @@ +--TEST-- +Multiple await operations on same coroutine +--FILE-- +getMessage() . "\n"; +} + +echo "second await on exception coroutine\n"; +try { + $result2b = await($coroutine2); + echo "should not succeed\n"; +} catch (\RuntimeException $e) { + echo "second caught: " . $e->getMessage() . "\n"; +} + +// Test 3: multiple awaits on cancelled coroutine +$coroutine3 = spawn(function() { + echo "coroutine3 executing\n"; + suspend(); + return "never_reached"; +}); + +$coroutine3->cancel(new \Async\CancellationException("Shared cancellation")); + +echo "first await on cancelled coroutine\n"; +try { + $result3a = await($coroutine3); + echo "should not succeed\n"; +} catch (\Async\CancellationException $e) { + echo "first caught cancellation: " . $e->getMessage() . "\n"; +} + +echo "second await on cancelled coroutine\n"; +try { + $result3b = await($coroutine3); + echo "should not succeed\n"; +} catch (\Async\CancellationException $e) { + echo "second caught cancellation: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +first await starting +coroutine1 executing +first await result: shared_result +second await starting +second await result: shared_result +third await starting +third await result: shared_result +first await on exception coroutine +coroutine2 executing +first caught: Shared error +second await on exception coroutine +second caught: Shared error +first await on cancelled coroutine +first caught cancellation: Shared cancellation +second await on cancelled coroutine +second caught cancellation: Shared cancellation +end \ No newline at end of file diff --git a/tests/await/069-await_manual_vs_timeout_cancel.phpt b/tests/await/069-await_manual_vs_timeout_cancel.phpt new file mode 100644 index 0000000..d5b2177 --- /dev/null +++ b/tests/await/069-await_manual_vs_timeout_cancel.phpt @@ -0,0 +1,97 @@ +--TEST-- +Comparison of manual cancellation vs timeout cancellation in await +--FILE-- +cancel(new \Async\CancellationException("Manual cancel message")); +echo "manual coroutine cancelled\n"; + +try { + $result = await($manual_coroutine); + echo "manual await should not succeed\n"; +} catch (\Async\CancellationException $e) { + echo "manual cancellation caught: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "manual unexpected: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +// Test 2: Timeout cancellation +$timeout_coroutine = spawn(function() { + echo "timeout coroutine started\n"; + delay(50); // Will be cancelled by timeout + echo "timeout coroutine should not complete\n"; + return "timeout_result"; +}); + +echo "timeout coroutine spawned\n"; + +try { + $result = await($timeout_coroutine, timeout(1)); + echo "timeout await should not succeed\n"; +} catch (\Async\TimeoutException $e) { + echo "timeout cancellation caught: " . get_class($e) . ": " . $e->getMessage() . "\n"; + $timeout_coroutine->cancel(new \Async\CancellationException("Timeout after 1 milliseconds")); +} catch (\Async\CancellationException $e) { + echo "timeout as cancellation: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "timeout unexpected: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +// Test 3: Race condition - manual cancel vs timeout +$race_coroutine = spawn(function() { + echo "race coroutine started\n"; + suspend(); + suspend(); + return "race_result"; +}); + +// Start coroutine +suspend(); + +// Cancel manually before timeout +$race_coroutine->cancel(new \Async\CancellationException("Manual wins")); + +try { + $result = await($race_coroutine, timeout(1)); // Should get manual cancel, not timeout + echo "race await should not succeed\n"; +} catch (\Async\CancellationException $e) { + echo "race cancellation caught: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} catch (\Async\TimeoutException $e) { + echo "race timeout caught: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "race unexpected: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +manual coroutine started +manual coroutine cancelled +manual cancellation caught: Async\CancellationException: Manual cancel message +timeout coroutine spawned +timeout coroutine started +timeout cancellation caught: Async\TimeoutException: Timeout occurred after 1 milliseconds +race coroutine started +race cancellation caught: Async\CancellationException: Manual wins +end \ No newline at end of file diff --git a/tests/await/070-await_state_transitions.phpt b/tests/await/070-await_state_transitions.phpt new file mode 100644 index 0000000..4e9f409 --- /dev/null +++ b/tests/await/070-await_state_transitions.phpt @@ -0,0 +1,77 @@ +--TEST-- +Complex state transitions in await operations +--FILE-- +isFinished() ? "true" : "false") . "\n"; + +// Try to cancel already completed coroutine +$completed_coroutine->cancel(new \Async\CancellationException("Too late")); +echo "attempted to cancel completed coroutine\n"; + +// Await should still return original result +$result1 = await($completed_coroutine); +echo "await completed result: $result1\n"; + +// Test 2: Await coroutine that completed with exception, then cancel +$exception_coroutine = spawn(function() { + echo "exception coroutine executing\n"; + throw new \RuntimeException("Original error"); +}); + +$original_exception = null; + +try { + await($exception_coroutine); +} catch (\RuntimeException $e) { + $original_exception = $e; +} + +echo "exception coroutine finished: " . ($exception_coroutine->isFinished() ? "true" : "false") . "\n"; + +// Try to cancel coroutine that already failed +$exception_coroutine->cancel(new \Async\CancellationException("Post-error cancel")); +echo "attempted to cancel failed coroutine\n"; + +// Should still get original exception +try { + $result2 = await($exception_coroutine); + echo "should not succeed\n"; +} catch (\RuntimeException $e) { + if($e === $original_exception) { + echo "original exception preserved: " . $e->getMessage() . "\n"; + } else { + echo "unexpected exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; + } +} catch (\Async\CancellationException $e) { + echo "unexpected cancellation: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +completed coroutine executing +coroutine finished: true +attempted to cancel completed coroutine +await completed result: already_done +exception coroutine executing +exception coroutine finished: true +attempted to cancel failed coroutine +original exception preserved: Original error +end \ No newline at end of file diff --git a/tests/context/002-context_inheritance.phpt b/tests/context/002-context_inheritance.phpt new file mode 100644 index 0000000..a3a52b3 --- /dev/null +++ b/tests/context/002-context_inheritance.phpt @@ -0,0 +1,114 @@ +--TEST-- +Context inheritance through scope hierarchy +--FILE-- +spawn(function() { + echo "parent coroutine started\n"; + + $context = \Async\currentContext(); + + // Set values in parent context + $context->set('parent_key', 'parent_value'); + $context->set('shared_key', 'from_parent'); + + echo "parent context values set\n"; + return "parent_done"; +}); + +$parent_coroutine->getResult(); + +// Create child scope that inherits from parent +$child_scope = \Async\Scope::inherit($parent_scope); + +// Test inheritance in child scope +$child_coroutine = $child_scope->spawn(function() { + echo "child coroutine started\n"; + + $context = \Async\currentContext(); + + // Test find() - should find parent values + echo "find parent_key: " . ($context->find('parent_key') ?: 'null') . "\n"; + echo "find shared_key: " . ($context->find('shared_key') ?: 'null') . "\n"; + + // Test get() - should find parent values + echo "get parent_key: " . ($context->get('parent_key') ?: 'null') . "\n"; + echo "get shared_key: " . ($context->get('shared_key') ?: 'null') . "\n"; + + // Test has() - should find parent values + echo "has parent_key: " . ($context->has('parent_key') ? 'true' : 'false') . "\n"; + echo "has shared_key: " . ($context->has('shared_key') ? 'true' : 'false') . "\n"; + + // Test findLocal() - should NOT find parent values + echo "findLocal parent_key: " . ($context->findLocal('parent_key') ?: 'null') . "\n"; + echo "findLocal shared_key: " . ($context->findLocal('shared_key') ?: 'null') . "\n"; + + // Test getLocal() - should NOT find parent values + echo "getLocal parent_key: " . ($context->getLocal('parent_key') ?: 'null') . "\n"; + echo "getLocal shared_key: " . ($context->getLocal('shared_key') ?: 'null') . "\n"; + + // Test hasLocal() - should NOT find parent values + echo "hasLocal parent_key: " . ($context->hasLocal('parent_key') ? 'true' : 'false') . "\n"; + echo "hasLocal shared_key: " . ($context->hasLocal('shared_key') ? 'true' : 'false') . "\n"; + + // Set local value that overrides parent + $context->set('shared_key', 'from_child'); + $context->set('child_key', 'child_value'); + + echo "child context values set\n"; + + // Test override behavior + echo "after override - find shared_key: " . ($context->find('shared_key') ?: 'null') . "\n"; + echo "after override - get shared_key: " . ($context->get('shared_key') ?: 'null') . "\n"; + echo "after override - findLocal shared_key: " . ($context->findLocal('shared_key') ?: 'null') . "\n"; + echo "after override - getLocal shared_key: " . ($context->getLocal('shared_key') ?: 'null') . "\n"; + + // Test local-only value + echo "findLocal child_key: " . ($context->findLocal('child_key') ?: 'null') . "\n"; + echo "getLocal child_key: " . ($context->getLocal('child_key') ?: 'null') . "\n"; + + return "child_done"; +}); + +await($child_coroutine); + +$child_coroutine->getResult(); + +echo "end\n"; + +?> +--EXPECT-- +start +parent coroutine started +parent context values set +child coroutine started +find parent_key: parent_value +find shared_key: from_parent +get parent_key: parent_value +get shared_key: from_parent +has parent_key: true +has shared_key: true +findLocal parent_key: null +findLocal shared_key: null +getLocal parent_key: null +getLocal shared_key: null +hasLocal parent_key: false +hasLocal shared_key: false +child context values set +after override - find shared_key: from_child +after override - get shared_key: from_child +after override - findLocal shared_key: from_child +after override - getLocal shared_key: from_child +findLocal child_key: child_value +getLocal child_key: child_value +end \ No newline at end of file diff --git a/tests/coroutine/004-coroutine_getException_running.phpt b/tests/coroutine/004-coroutine_getException_running.phpt index cc3c344..720c71e 100644 --- a/tests/coroutine/004-coroutine_getException_running.phpt +++ b/tests/coroutine/004-coroutine_getException_running.phpt @@ -9,13 +9,12 @@ $coroutine = spawn(function() { return "test"; }); -try { - $coroutine->getException(); - echo "Should not reach here\n"; -} catch (Async\AsyncException $e) { - echo "Caught: " . $e->getMessage() . "\n"; +if($coroutine->getException() === null) { + echo "No exception\n"; +} else { + echo "Exception: " . get_class($coroutine->getException()) . "\n"; } ?> --EXPECT-- -Caught: Cannot get exception of a running coroutine \ No newline at end of file +No exception \ No newline at end of file diff --git a/tests/coroutine/006-coroutine_cancel_basic.phpt b/tests/coroutine/006-coroutine_cancel_basic.phpt index 9ecbcd1..241f916 100644 --- a/tests/coroutine/006-coroutine_cancel_basic.phpt +++ b/tests/coroutine/006-coroutine_cancel_basic.phpt @@ -14,6 +14,7 @@ $coroutine = spawn(function() { $cancellation = new CancellationException("test cancellation"); $coroutine->cancel($cancellation); +var_dump($coroutine->isCancellationRequested()); var_dump($coroutine->isCancelled()); try { @@ -25,4 +26,5 @@ try { ?> --EXPECT-- bool(true) +bool(false) Caught: test cancellation \ No newline at end of file diff --git a/tests/coroutine/026-coroutine_getAwaitingInfo_detailed.phpt b/tests/coroutine/026-coroutine_getAwaitingInfo_detailed.phpt new file mode 100644 index 0000000..5b53049 --- /dev/null +++ b/tests/coroutine/026-coroutine_getAwaitingInfo_detailed.phpt @@ -0,0 +1,58 @@ +--TEST-- +Coroutine: getAwaitingInfo() - detailed testing with different states +--FILE-- +getAwaitingInfo(); +echo "Running coroutine info type: " . gettype($info) . "\n"; +echo "Running coroutine info is array: " . (is_array($info) ? "true" : "false") . "\n"; + +// Wait for completion +await($running); + +// Test 2: getAwaitingInfo() for finished coroutine +$info2 = $running->getAwaitingInfo(); +echo "Finished coroutine info type: " . gettype($info2) . "\n"; +echo "Finished coroutine info is array: " . (is_array($info2) ? "true" : "false") . "\n"; + +// Test 3: getAwaitingInfo() for suspended coroutine +$suspended = spawn(function() { + suspend(); + return "suspended"; +}); + +$info3 = $suspended->getAwaitingInfo(); +echo "Suspended coroutine info type: " . gettype($info3) . "\n"; +echo "Suspended coroutine info is array: " . (is_array($info3) ? "true" : "false") . "\n"; + +// Test 4: getAwaitingInfo() for cancelled coroutine +$suspended->cancel(); +$info4 = $suspended->getAwaitingInfo(); +echo "Cancelled coroutine info type: " . gettype($info4) . "\n"; +echo "Cancelled coroutine info is array: " . (is_array($info4) ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Running coroutine info type: array +Running coroutine info is array: true +Finished coroutine info type: array +Finished coroutine info is array: true +Suspended coroutine info type: array +Suspended coroutine info is array: true +Cancelled coroutine info type: array +Cancelled coroutine info is array: true +end \ No newline at end of file diff --git a/tests/coroutine/028-coroutine_state_transitions.phpt b/tests/coroutine/028-coroutine_state_transitions.phpt new file mode 100644 index 0000000..8c689c2 --- /dev/null +++ b/tests/coroutine/028-coroutine_state_transitions.phpt @@ -0,0 +1,126 @@ +--TEST-- +Coroutine state transitions and edge cases +--FILE-- +isQueued() ? "true" : "false") . "\n"; +echo "before suspend - isStarted: " . ($queued_coroutine->isStarted() ? "true" : "false") . "\n"; + +suspend(); // Let coroutine start + +echo "after start - isQueued: " . ($queued_coroutine->isQueued() ? "true" : "false") . "\n"; +echo "after start - isStarted: " . ($queued_coroutine->isStarted() ? "true" : "false") . "\n"; +echo "after start - isSuspended: " . ($queued_coroutine->isSuspended() ? "true" : "false") . "\n"; + +// Test 2: getResult() on non-finished coroutine states +$running_coroutine = spawn(function() { + echo "running coroutine started\n"; + suspend(); + echo "running coroutine continuing\n"; + return "running_result"; +}); + +suspend(); // Let it start and suspend + +echo "suspended state - isFinished: " . ($running_coroutine->isFinished() ? "true" : "false") . "\n"; + +try { + $result = $running_coroutine->getResult(); + echo "getResult: "; + var_dump($result); +} catch (\Error $e) { + echo "getResult on suspended failed: " . get_class($e) . "\n"; +} catch (Throwable $e) { + echo "getResult unexpected: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +// Test 3: getException() on various states +$exception_coroutine = spawn(function() { + echo "exception coroutine started\n"; + suspend(); + throw new \RuntimeException("Test exception"); +}); + +try { + await($exception_coroutine); +} catch (\RuntimeException $e) { +} catch (Throwable $e) { + echo "Unexpected exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "before exception - getException: "; +try { + $exception = $exception_coroutine->getException(); + echo ($exception ? get_class($exception) : "null") . "\n"; +} catch (\Error $e) { + echo "Error: " . get_class($e) . "\n"; +} + +suspend(); // Let it throw + +echo "after exception - getException: "; +try { + $exception = $exception_coroutine->getException(); + echo get_class($exception) . ": " . $exception->getMessage() . "\n"; +} catch (Throwable $e) { + echo "Unexpected: " . get_class($e) . "\n"; +} + +// Test 4: isCancellationRequested() functionality +$cancel_request_coroutine = spawn(function() { + echo "cancel request coroutine started\n"; + suspend(); + echo "cancel request coroutine continuing\n"; + return "cancel_result"; +}); + +suspend(); // Let it start + +echo "before cancel request - isCancellationRequested: " . ($cancel_request_coroutine->isCancellationRequested() ? "true" : "false") . "\n"; +echo "before cancel request - isCancelled: " . ($cancel_request_coroutine->isCancelled() ? "true" : "false") . "\n"; + +$cancel_request_coroutine->cancel(new \Async\CancellationException("Test cancellation")); + +echo "after cancel request - isCancellationRequested: " . ($cancel_request_coroutine->isCancellationRequested() ? "true" : "false") . "\n"; + +suspend(); // Let cancellation propagate + +echo "after cancel request - isCancelled: " . ($cancel_request_coroutine->isCancelled() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +before suspend - isQueued: true +before suspend - isStarted: false +queued coroutine executing +after start - isQueued: true +after start - isStarted: true +after start - isSuspended: true +running coroutine started +suspended state - isFinished: false +getResult: NULL +running coroutine continuing +exception coroutine started +before exception - getException: RuntimeException +after exception - getException: RuntimeException: Test exception +cancel request coroutine started +before cancel request - isCancellationRequested: false +before cancel request - isCancelled: false +after cancel request - isCancellationRequested: true +after cancel request - isCancelled: true +end \ No newline at end of file diff --git a/tests/coroutine/029-coroutine_deferred_cancellation_basic.phpt b/tests/coroutine/029-coroutine_deferred_cancellation_basic.phpt new file mode 100644 index 0000000..bcb3fa5 --- /dev/null +++ b/tests/coroutine/029-coroutine_deferred_cancellation_basic.phpt @@ -0,0 +1,64 @@ +--TEST-- +Basic coroutine deferred cancellation with protected operation +--FILE-- +cancel(new \Async\CancellationException("Deferred cancellation")); + +echo "protected coroutine cancelled: " . ($protected_coroutine->isCancelled() ? "true" : "false") . "\n"; +echo "cancellation requested: " . ($protected_coroutine->isCancellationRequested() ? "true" : "false") . "\n"; + +// Let protected operation complete +suspend(); + +echo "after protected completion - cancelled: " . ($protected_coroutine->isCancelled() ? "true" : "false") . "\n"; + +try { + await($protected_coroutine); +} catch (\Async\CancellationException $e) { +} + +$result = $protected_coroutine->getResult(); + +echo "protected result: "; +var_dump($result); + +echo "end\n"; + +?> +--EXPECTF-- +start +protected coroutine started +inside protected operation +cancelling protected coroutine +protected coroutine cancelled: false +cancellation requested: true +protected operation completed +after protected completion - cancelled: true +protected result: NULL +end \ No newline at end of file diff --git a/tests/coroutine/030-coroutine_deferred_cancellation_multiple.phpt b/tests/coroutine/030-coroutine_deferred_cancellation_multiple.phpt new file mode 100644 index 0000000..9aad519 --- /dev/null +++ b/tests/coroutine/030-coroutine_deferred_cancellation_multiple.phpt @@ -0,0 +1,55 @@ +--TEST-- +Multiple deferred cancellations with sequential protect blocks +--FILE-- +cancel(new \Async\CancellationException("Multi deferred")); +echo "multi cancelled during first protection\n"; + +try { + await($multi_protected); +} catch (\Async\CancellationException $e) { + echo "multi deferred cancellation: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +multi protected started +first protected operation +multi cancelled during first protection +first protected completed +multi deferred cancellation: Multi deferred +end \ No newline at end of file diff --git a/tests/coroutine/031-coroutine_deferred_cancellation_during_protection.phpt b/tests/coroutine/031-coroutine_deferred_cancellation_during_protection.phpt new file mode 100644 index 0000000..f8ec75d --- /dev/null +++ b/tests/coroutine/031-coroutine_deferred_cancellation_during_protection.phpt @@ -0,0 +1,53 @@ +--TEST-- +Cancellation of coroutine during protected operation with exception handling +--FILE-- +getMessage() . "\n"; + throw $e; + } + + return "should_not_reach"; +}); + +suspend(); // Enter protection + +// Cancel while protected +$already_protected->cancel(new \Async\CancellationException("Cancel during protection")); + +try { + await($already_protected); +} catch (\Async\CancellationException $e) { + echo "protection cancellation: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +already protected started +protection started +protection completed +caught cancellation in coroutine: Cancel during protection +protection cancellation: Cancel during protection +end \ No newline at end of file diff --git a/tests/coroutine/032-coroutine_composite_exception.phpt b/tests/coroutine/032-coroutine_composite_exception.phpt new file mode 100644 index 0000000..6b0ab19 --- /dev/null +++ b/tests/coroutine/032-coroutine_composite_exception.phpt @@ -0,0 +1,71 @@ +--TEST-- +CompositeException with multiple finally handlers +--FILE-- +setExceptionHandler(function($scope, $coroutine, $exception) { + + if(!$exception instanceof \Async\CompositeException) { + echo "caught exception: {$exception->getMessage()}\n"; + return; + } + + foreach ($exception->getExceptions() as $i => $error) { + $type = get_class($error); + echo "error {$i}: {$type}: {$error->getMessage()}\n"; + } +}); + +$composite_coroutine = $scope->spawn(function() { + echo "composite coroutine started\n"; + + $coroutine = \Async\currentCoroutine(); + + // Add multiple finally handlers that throw + $coroutine->onFinally(function() { + echo "finally 1 executing\n"; + throw new \RuntimeException("Finally 1 error"); + }); + + $coroutine->onFinally(function() { + echo "finally 2 executing\n"; + throw new \InvalidArgumentException("Finally 2 error"); + }); + + $coroutine->onFinally(function() { + echo "finally 3 executing\n"; + throw new \LogicException("Finally 3 error"); + }); + + suspend(); + throw new \RuntimeException("coroutine error"); +}); + +try { + await($composite_coroutine); +} catch (Throwable $e) { + echo "caught: {$e->getMessage()}\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +composite coroutine started +finally 1 executing +finally 2 executing +finally 3 executing +error 0: RuntimeException: Finally 1 error +error 1: InvalidArgumentException: Finally 2 error +error 2: LogicException: Finally 3 error +caught: coroutine error +end \ No newline at end of file diff --git a/tests/coroutine/033-coroutine_onFinally_invalid_callback.phpt b/tests/coroutine/033-coroutine_onFinally_invalid_callback.phpt new file mode 100644 index 0000000..8d55ff1 --- /dev/null +++ b/tests/coroutine/033-coroutine_onFinally_invalid_callback.phpt @@ -0,0 +1,63 @@ +--TEST-- +Coroutine onFinally with invalid callback parameters +--FILE-- +onFinally("not_a_callable"); + echo "should not accept string as callback\n"; + } catch (\TypeError $e) { + echo "caught TypeError for string: " . $e->getMessage() . "\n"; + } catch (Throwable $e) { + echo "unexpected for string: " . get_class($e) . "\n"; + } + + try { + $coroutine->onFinally(123); + echo "should not accept integer as callback\n"; + } catch (\TypeError $e) { + echo "caught TypeError for integer: " . $e->getMessage() . "\n"; + } catch (Throwable $e) { + echo "unexpected for integer: " . get_class($e) . "\n"; + } + + try { + $coroutine->onFinally(null); + echo "should not accept null as callback\n"; + } catch (\TypeError $e) { + echo "caught TypeError for null: " . $e->getMessage() . "\n"; + } catch (Throwable $e) { + echo "unexpected for null: " . get_class($e) . "\n"; + } + + return "invalid_finally_result"; +}); + +suspend(); + +$result = $invalid_finally_coroutine->getResult(); +echo "invalid finally result: $result\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +invalid finally coroutine started +caught TypeError for string: %s +caught TypeError for integer: %s +caught TypeError for null: %s +invalid finally result: invalid_finally_result +end \ No newline at end of file diff --git a/tests/coroutine/034-coroutine_cancel_invalid_exception.phpt b/tests/coroutine/034-coroutine_cancel_invalid_exception.phpt new file mode 100644 index 0000000..3e2c664 --- /dev/null +++ b/tests/coroutine/034-coroutine_cancel_invalid_exception.phpt @@ -0,0 +1,48 @@ +--TEST-- +Coroutine cancel with invalid exception types +--FILE-- +cancel("not an exception"); + echo "should not accept string for cancel\n"; +} catch (\TypeError $e) { + echo "cancel string TypeError: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "cancel string unexpected: " . get_class($e) . "\n"; +} + +try { + $invalid_cancel_coroutine->cancel(new \RuntimeException("Wrong exception type")); + echo "accepted RuntimeException for cancel\n"; +} catch (\TypeError $e) { + echo "cancel RuntimeException TypeError: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "cancel RuntimeException unexpected: " . get_class($e) . "\n"; +} + +// Valid cancellation +$invalid_cancel_coroutine->cancel(new \Async\CancellationException("Valid cancellation")); + +echo "end\n"; + +?> +--EXPECTF-- +start +invalid cancel coroutine started +cancel string TypeError: %a +cancel RuntimeException TypeError:%a +end \ No newline at end of file diff --git a/tests/coroutine/035-coroutine_deep_recursion.phpt b/tests/coroutine/035-coroutine_deep_recursion.phpt new file mode 100644 index 0000000..c31bf12 --- /dev/null +++ b/tests/coroutine/035-coroutine_deep_recursion.phpt @@ -0,0 +1,43 @@ +--TEST-- +Coroutine with deep recursion and stack limits +--FILE-- += $maxDepth) { + echo "reached max depth: $depth\n"; + return $depth; + } + + if ($depth % 20 === 0) { + suspend(); // Suspend periodically + } + + return deepRecursionTest($depth + 1, $maxDepth); + } + + $result = deepRecursionTest(0); + return "recursion_result_$result"; +}); + +$result = await($deep_recursion_coroutine); +echo "deep recursion result: $result\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +deep recursion coroutine started +reached max depth: 1000 +deep recursion result: recursion_result_1000 +end \ No newline at end of file diff --git a/tests/coroutine/036-coroutine_gc_circular_finally.phpt b/tests/coroutine/036-coroutine_gc_circular_finally.phpt new file mode 100644 index 0000000..66188ed --- /dev/null +++ b/tests/coroutine/036-coroutine_gc_circular_finally.phpt @@ -0,0 +1,47 @@ +--TEST-- +Coroutine: Circular references between coroutines and finally handlers +--FILE-- +coroutine = $coroutine; + + $coroutine->onFinally(function() use ($data) { + echo "circular finally executed\n"; + $data->cleanup = "done"; + // $data holds reference to coroutine, creating cycle + }); + + suspend(); + return "circular_result"; +}); + +await($circular_finally_coroutine); +$result = $circular_finally_coroutine->getResult(); +echo "circular result: $result\n"; + +// Force garbage collection +gc_collect_cycles(); +echo "gc after circular references\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +circular finally coroutine started +circular finally executed +circular result: circular_result +gc after circular references +end \ No newline at end of file diff --git a/tests/edge_cases/001-deadlock-basic-test.phpt b/tests/edge_cases/001-deadlock-basic-test.phpt new file mode 100644 index 0000000..1a9b63f --- /dev/null +++ b/tests/edge_cases/001-deadlock-basic-test.phpt @@ -0,0 +1,41 @@ +--TEST-- +Deadlock basic test +--FILE-- + +--EXPECTF-- +start +end +coroutine1 running +coroutine2 running + +Warning: no active coroutines, deadlock detected. Coroutines in waiting: %d in Unknown on line %d + +Warning: the coroutine was suspended in file: %s, line: %d will be canceled in Unknown on line %d + +Warning: the coroutine was suspended in file: %s, line: %d will be canceled in Unknown on line %d diff --git a/tests/edge_cases/002-deadlock-with-catch.phpt b/tests/edge_cases/002-deadlock-with-catch.phpt new file mode 100644 index 0000000..f13b1a6 --- /dev/null +++ b/tests/edge_cases/002-deadlock-with-catch.phpt @@ -0,0 +1,52 @@ +--TEST-- +Deadlock occurs when a coroutine continues execution after being cancelled. +--FILE-- +getMessage() . "\n"; + } + echo "coroutine1 finished\n"; +}); + +$coroutine2 = spawn(function() use ($coroutine1) { + echo "coroutine2 running\n"; + suspend(); // Yield to allow the coroutine to start + try { + await($coroutine1); + } catch (Throwable $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; + } + echo "coroutine2 finished\n"; +}); + +echo "end\n"; +?> +--EXPECTF-- +start +end +coroutine1 running +coroutine2 running + +Warning: no active coroutines, deadlock detected. Coroutines in waiting: %d in Unknown on line %d + +Warning: the coroutine was suspended in file: %s, line: %d will be canceled in Unknown on line %d + +Warning: the coroutine was suspended in file: %s, line: %d will be canceled in Unknown on line %d +Caught exception: Deadlock detected +coroutine1 finished +coroutine2 finished \ No newline at end of file diff --git a/tests/edge_cases/003-deadlock-with-zombie.phpt b/tests/edge_cases/003-deadlock-with-zombie.phpt new file mode 100644 index 0000000..371988a --- /dev/null +++ b/tests/edge_cases/003-deadlock-with-zombie.phpt @@ -0,0 +1,58 @@ +--TEST-- +Deadlock - The coroutine not only continues execution but also performs a suspend. +--FILE-- +getMessage() . "\n"; + } + + suspend(); + + echo "coroutine1 finished\n"; +}); + +$coroutine2 = spawn(function() use ($coroutine1) { + echo "coroutine2 running\n"; + suspend(); // Yield to allow the coroutine to start + try { + await($coroutine1); + } catch (Throwable $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; + } + + suspend(); + + echo "coroutine2 finished\n"; +}); + +echo "end\n"; +?> +--EXPECTF-- +start +end +coroutine1 running +coroutine2 running + +Warning: no active coroutines, deadlock detected. Coroutines in waiting: %d in Unknown on line %d + +Warning: the coroutine was suspended in file: %s, line: %d will be canceled in Unknown on line %d + +Warning: the coroutine was suspended in file: %s, line: %d will be canceled in Unknown on line %d +Caught exception: Deadlock detected +coroutine1 finished +coroutine2 finished \ No newline at end of file diff --git a/tests/edge_cases/004-scope_provider_exceptions.phpt b/tests/edge_cases/004-scope_provider_exceptions.phpt new file mode 100644 index 0000000..2b756ea --- /dev/null +++ b/tests/edge_cases/004-scope_provider_exceptions.phpt @@ -0,0 +1,71 @@ +--TEST-- +ScopeProvider - exception handling with different exception types +--FILE-- +exceptionType = $type; + } + + public function provideScope(): ?\Async\Scope + { + switch($this->exceptionType) { + case 'runtime': + throw new \RuntimeException("Runtime error in provider"); + case 'cancellation': + throw new \Async\CancellationException("Cancelled in provider"); + case 'invalid_argument': + throw new \InvalidArgumentException("Invalid argument in provider"); + case 'logic': + throw new \LogicException("Logic error in provider"); + default: + throw new \Exception("Generic error in provider"); + } + } +} + +// Test different exception types +$exceptionTypes = ['runtime', 'cancellation', 'invalid_argument', 'logic', 'generic']; + +foreach ($exceptionTypes as $type) { + try { + $coroutine = spawnWith(new ThrowingScopeProvider($type), function() { + return "test"; + }); + echo "ERROR: Should have thrown exception for {$type}\n"; + } catch (\Async\CancellationException $e) { + echo "Caught CancellationException: " . $e->getMessage() . "\n"; + } catch (\RuntimeException $e) { + echo "Caught RuntimeException: " . $e->getMessage() . "\n"; + } catch (\InvalidArgumentException $e) { + echo "Caught InvalidArgumentException: " . $e->getMessage() . "\n"; + } catch (\LogicException $e) { + echo "Caught LogicException: " . $e->getMessage() . "\n"; + } catch (\Exception $e) { + echo "Caught Exception: " . $e->getMessage() . "\n"; + } catch (Throwable $e) { + echo "Caught Throwable: " . get_class($e) . ": " . $e->getMessage() . "\n"; + } +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught RuntimeException: Runtime error in provider +Caught CancellationException: Cancelled in provider +Caught InvalidArgumentException: Invalid argument in provider +Caught LogicException: Logic error in provider +Caught Exception: Generic error in provider +end \ No newline at end of file diff --git a/tests/edge_cases/005-scheduler_shutdown_basic.phpt b/tests/edge_cases/005-scheduler_shutdown_basic.phpt new file mode 100644 index 0000000..60585cb --- /dev/null +++ b/tests/edge_cases/005-scheduler_shutdown_basic.phpt @@ -0,0 +1,54 @@ +--TEST-- +Scheduler: shutdown functionality and cleanup +--FILE-- +getMessage() . "\n"; +} + +// Check coroutine states after shutdown +echo "coroutine1 finished: " . ($coroutine1->isFinished() ? "true" : "false") . "\n"; +echo "coroutine2 finished: " . ($coroutine2->isFinished() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +coroutines spawned +coroutine1 running +coroutine2 running +coroutine1 after suspend +coroutine2 after suspend +coroutine1 finished: true +coroutine2 finished: true +end \ No newline at end of file diff --git a/tests/edge_cases/006-scheduler_graceful_shutdown_exceptions.phpt b/tests/edge_cases/006-scheduler_graceful_shutdown_exceptions.phpt new file mode 100644 index 0000000..fce0edb --- /dev/null +++ b/tests/edge_cases/006-scheduler_graceful_shutdown_exceptions.phpt @@ -0,0 +1,57 @@ +--TEST-- +Scheduler: graceful shutdown with exception handling +--FILE-- +getMessage() . "\n"; +} catch (Throwable $e) { + echo "caught shutdown exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +// Check states after shutdown +echo "error coroutine finished: " . ($error_coroutine->isFinished() ? "true" : "false") . "\n"; +echo "cleanup coroutine finished: " . ($cleanup_coroutine->isFinished() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +coroutines spawned +error coroutine started +cleanup coroutine started +cleanup coroutine running +graceful shutdown with custom cancellation completed +error coroutine finished: true +cleanup coroutine finished: true +end \ No newline at end of file diff --git a/tests/info/001-info-getCoroutines-basic.phpt b/tests/info/001-info-getCoroutines-basic.phpt new file mode 100644 index 0000000..b49fc05 --- /dev/null +++ b/tests/info/001-info-getCoroutines-basic.phpt @@ -0,0 +1,56 @@ +--TEST-- +getCoroutines() - basic functionality and lifecycle tracking +--FILE-- +cancel(); +suspend(); // Allow cancellation to propagate +$coroutines = getCoroutines(); +echo "After first cancel count: " . count($coroutines) . "\n"; + +$c2->cancel(); +suspend(); // Allow cancellation to propagate +$coroutines = getCoroutines(); +echo "After second cancel count: " . count($coroutines) . "\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +Initial coroutines count: 0 +Initial coroutines type: array +Active coroutines count: 3 +First coroutine is Coroutine: true +Second coroutine is Coroutine: true +After first cancel count: 2 +After second cancel count: 1 +end \ No newline at end of file diff --git a/tests/info/002-info_getCoroutines_integration.phpt b/tests/info/002-info_getCoroutines_integration.phpt new file mode 100644 index 0000000..afef3a6 --- /dev/null +++ b/tests/info/002-info_getCoroutines_integration.phpt @@ -0,0 +1,89 @@ +--TEST-- +getCoroutines() - integration with coroutine lifecycle management +--FILE-- + $coroutine) { + if (!in_array($coroutine, $all_coroutines, true)) { + echo "ERROR: Coroutine $index not found in getCoroutines()\n"; + } +} + +if (!in_array($currentCoroutine, $all_coroutines, true)) { + echo "ERROR: Current coroutine not found in getCoroutines()\n"; +} + +foreach ($coroutines as $index => $coroutine) { + echo "Coroutine {$index} is suspended: " . ($coroutine->isSuspended() ? "true" : "false") . "\n"; +} + +// Test 2: Cancel some coroutines +$coroutines[0]->cancel(); +$coroutines[2]->cancel(); + +// Check if status is updated +foreach ($coroutines as $index => $coroutine) { + echo "Coroutine {$index} is isCancellationRequested: " . ($coroutine->isCancellationRequested() ? "true" : "false") . "\n"; +} + +$results = awaitAllWithErrors($coroutines); // Ensure we yield to allow cancellation to take effect + +$after_partial_cancel = count(getCoroutines()) - $initial_count; +echo "After cancelling 2: {$after_partial_cancel}\n"; + +echo "Completed results: " . count($results[0]) . "\n"; + +$final_count = count(getCoroutines()) - $initial_count; +echo "Final count: {$final_count}\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +Initial count: 0 +After spawning 5: 6 +Coroutine 0 is suspended: true +Coroutine 1 is suspended: true +Coroutine 2 is suspended: true +Coroutine 3 is suspended: true +Coroutine 4 is suspended: true +Coroutine 0 is isCancellationRequested: true +Coroutine 1 is isCancellationRequested: false +Coroutine 2 is isCancellationRequested: true +Coroutine 3 is isCancellationRequested: false +Coroutine 4 is isCancellationRequested: false +After cancelling 2: 1 +Completed results: 3 +Final count: 1 +end \ No newline at end of file diff --git a/tests/protect/001-protect_basic.phpt b/tests/protect/001-protect_basic.phpt new file mode 100644 index 0000000..33a91de --- /dev/null +++ b/tests/protect/001-protect_basic.phpt @@ -0,0 +1,20 @@ +--TEST-- +Async\protect: basic usage +--FILE-- + +--EXPECT-- +start +protected block +end \ No newline at end of file diff --git a/tests/protect/002-protect_return_value.phpt b/tests/protect/002-protect_return_value.phpt new file mode 100644 index 0000000..202d2df --- /dev/null +++ b/tests/protect/002-protect_return_value.phpt @@ -0,0 +1,16 @@ +--TEST-- +Async\protect: should return a value +--FILE-- + +--EXPECT-- +string(10) "test value" \ No newline at end of file diff --git a/tests/protect/003-protect_nested.phpt b/tests/protect/003-protect_nested.phpt new file mode 100644 index 0000000..7254b78 --- /dev/null +++ b/tests/protect/003-protect_nested.phpt @@ -0,0 +1,28 @@ +--TEST-- +Async\protect: nested protect calls +--FILE-- + +--EXPECT-- +start +outer protect start +inner protect +outer protect end +end \ No newline at end of file diff --git a/tests/protect/004-protect_cancellation_deferred.phpt b/tests/protect/004-protect_cancellation_deferred.phpt new file mode 100644 index 0000000..4f0bac7 --- /dev/null +++ b/tests/protect/004-protect_cancellation_deferred.phpt @@ -0,0 +1,35 @@ +--TEST-- +Async\protect: cancellation is deferred during protected block +--FILE-- +cancel(); + +// Wait for completion +await($coroutine); + +?> +--EXPECTF-- +coroutine start +protected block start +protected block end \ No newline at end of file diff --git a/tests/protect/005-protect_cancellation_immediate.phpt b/tests/protect/005-protect_cancellation_immediate.phpt new file mode 100644 index 0000000..568ca57 --- /dev/null +++ b/tests/protect/005-protect_cancellation_immediate.phpt @@ -0,0 +1,38 @@ +--TEST-- +Async\protect: cancellation applied immediately after protected block +--FILE-- +getMessage() . "\n"; + } +}); + +suspend(); + +// Cancel the coroutine +$coroutine->cancel(); + +await($coroutine); + +?> +--EXPECTF-- +before protect +in protect +finished protect +caught exception: %s \ No newline at end of file diff --git a/tests/protect/006-protect_multiple_cancellation.phpt b/tests/protect/006-protect_multiple_cancellation.phpt new file mode 100644 index 0000000..48b9605 --- /dev/null +++ b/tests/protect/006-protect_multiple_cancellation.phpt @@ -0,0 +1,41 @@ +--TEST-- +Async\protect: multiple cancellation attempts during protected block +--FILE-- +cancel(); +suspend(); +$coroutine->cancel(); +suspend(); +$coroutine->cancel(); + +await($coroutine); + +?> +--EXPECTF-- +coroutine start +protected block +work: 1 +work: 2 \ No newline at end of file diff --git a/tests/protect/007-protect_exception_in_closure.phpt b/tests/protect/007-protect_exception_in_closure.phpt new file mode 100644 index 0000000..4abf5c1 --- /dev/null +++ b/tests/protect/007-protect_exception_in_closure.phpt @@ -0,0 +1,27 @@ +--TEST-- +Async\protect: exception thrown inside protected closure +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +before exception +caught exception: test exception +end \ No newline at end of file diff --git a/tests/protect/008-protect_with_exception.phpt b/tests/protect/008-protect_with_exception.phpt new file mode 100644 index 0000000..7f3b058 --- /dev/null +++ b/tests/protect/008-protect_with_exception.phpt @@ -0,0 +1,26 @@ +--TEST-- +Async\protect: exception handling in protected closure +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +protected closure +caught exception: Exception +end \ No newline at end of file diff --git a/tests/protect/009-protect_with_spawn.phpt b/tests/protect/009-protect_with_spawn.phpt new file mode 100644 index 0000000..3f620d5 --- /dev/null +++ b/tests/protect/009-protect_with_spawn.phpt @@ -0,0 +1,40 @@ +--TEST-- +Async\protect: protect inside spawn coroutine +--FILE-- + +--EXPECT-- +start +spawn start +protected in spawn +result: 3 +spawn end +final result: spawn result +end \ No newline at end of file diff --git a/tests/protect/010-protect_with_await.phpt b/tests/protect/010-protect_with_await.phpt new file mode 100644 index 0000000..09c204f --- /dev/null +++ b/tests/protect/010-protect_with_await.phpt @@ -0,0 +1,49 @@ +--TEST-- +Async\protect: protect with await operations +--FILE-- + +--EXPECT-- +start +child coroutine +main start +protected block start +await result: child result +protected block end +main end +final result: main result +end \ No newline at end of file diff --git a/tests/protect/011-protect_invalid_parameter.phpt b/tests/protect/011-protect_invalid_parameter.phpt new file mode 100644 index 0000000..389ef48 --- /dev/null +++ b/tests/protect/011-protect_invalid_parameter.phpt @@ -0,0 +1,39 @@ +--TEST-- +Async\protect: invalid parameter types +--FILE-- +getMessage() . "\n"; +} + +// Test with array +try { + protect([]); +} catch (TypeError $e) { + echo "caught TypeError for array\n"; +} + +// Test with object +try { + protect(new stdClass()); +} catch (TypeError $e) { + echo "caught TypeError for object\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +caught TypeError: %s +caught TypeError for array +caught TypeError for object +end \ No newline at end of file diff --git a/tests/protect/012-protect_closure_required.phpt b/tests/protect/012-protect_closure_required.phpt new file mode 100644 index 0000000..163e8e9 --- /dev/null +++ b/tests/protect/012-protect_closure_required.phpt @@ -0,0 +1,31 @@ +--TEST-- +Async\protect: closure parameter is required +--FILE-- +getMessage() . "\n"; +} + +// Test with too many parameters +try { + protect(function() {}, "extra param"); +} catch (ArgumentCountError $e) { + echo "caught ArgumentCountError for too many params\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +caught ArgumentCountError: %s +caught ArgumentCountError for too many params +end \ No newline at end of file diff --git a/tests/scope/022-scope_awaitCompletion_basic.phpt b/tests/scope/022-scope_awaitCompletion_basic.phpt new file mode 100644 index 0000000..df6e83d --- /dev/null +++ b/tests/scope/022-scope_awaitCompletion_basic.phpt @@ -0,0 +1,58 @@ +--TEST-- +Scope: awaitCompletion() - basic usage +--FILE-- +spawn(function() { + echo "coroutine1 running\n"; + return "result1"; +}); + +$coroutine2 = $scope->spawn(function() { + echo "coroutine2 running\n"; + return "result2"; +}); + +echo "spawned coroutines\n"; + +// Await completion from external scope +$external = spawn(function() use ($scope) { + echo "external waiting for scope completion\n"; + $scope->awaitCompletion(timeout(1000)); + echo "scope completed\n"; +}); + +echo "awaiting external\n"; +await($external); + +echo "verifying results\n"; +echo "coroutine1 result: " . $coroutine1->getResult() . "\n"; +echo "coroutine2 result: " . $coroutine2->getResult() . "\n"; +echo "scope finished: " . ($scope->isFinished() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +spawned coroutines +awaiting external +coroutine1 running +coroutine2 running +external waiting for scope completion +scope completed +verifying results +coroutine1 result: result1 +coroutine2 result: result2 +scope finished: true +end \ No newline at end of file diff --git a/tests/scope/023-scope_awaitCompletion_timeout.phpt b/tests/scope/023-scope_awaitCompletion_timeout.phpt new file mode 100644 index 0000000..dc841ee --- /dev/null +++ b/tests/scope/023-scope_awaitCompletion_timeout.phpt @@ -0,0 +1,57 @@ +--TEST-- +Scope: awaitCompletion() - timeout handling +--FILE-- +spawn(function() { + echo "long running coroutine started\n"; + suspend(); + suspend(); + suspend(); + echo "long running coroutine finished\n"; + return "delayed_result"; +}); + +echo "spawned long running coroutine\n"; + +// Try to await completion with short timeout +$external = spawn(function() use ($scope) { + echo "external waiting with timeout\n"; + try { + $scope->awaitCompletion(timeout(50)); + echo "ERROR: Should have timed out\n"; + } catch (\Async\TimeoutException $e) { + echo "caught timeout as expected\n"; + } +}); + +await($external); + +// Cancel the long running coroutine to clean up +$long_running->cancel(); + +echo "scope finished: " . ($scope->isFinished() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +spawned long running coroutine +long running coroutine started +external waiting with timeout +long running coroutine finished +caught timeout as expected +scope finished: true +end \ No newline at end of file diff --git a/tests/scope/024-scope_awaitAfterCancellation_basic.phpt b/tests/scope/024-scope_awaitAfterCancellation_basic.phpt new file mode 100644 index 0000000..a845a78 --- /dev/null +++ b/tests/scope/024-scope_awaitAfterCancellation_basic.phpt @@ -0,0 +1,66 @@ +--TEST-- +Scope: awaitAfterCancellation() - basic usage +--FILE-- +spawn(function() { + echo "coroutine1 started\n"; + suspend(); + suspend(); + echo "coroutine1 finished\n"; + return "result1"; +}); + +$coroutine2 = $scope->spawn(function() { + echo "coroutine2 started\n"; + suspend(); + suspend(); + echo "coroutine2 finished\n"; + return "result2"; +}); + +echo "spawned coroutines\n"; + +suspend(); // Let coroutines start + +// Cancel the scope +$scope->cancel(); +echo "scope cancelled\n"; + +// Await after cancellation from external context +$external = spawn(function() use ($scope) { + echo "external waiting after cancellation\n"; + $scope->awaitAfterCancellation(null, timeout(1000)); + echo "awaitAfterCancellation completed\n"; +}); + +await($external); + +echo "scope finished: " . ($scope->isFinished() ? "true" : "false") . "\n"; +echo "scope closed: " . ($scope->isClosed() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +spawned coroutines +coroutine1 started +coroutine2 started +scope cancelled +external waiting after cancellation +awaitAfterCancellation completed +scope finished: true +scope closed: true +end \ No newline at end of file diff --git a/tests/scope/025-scope_awaitAfterCancellation_error_handler.phpt b/tests/scope/025-scope_awaitAfterCancellation_error_handler.phpt new file mode 100644 index 0000000..f9924ea --- /dev/null +++ b/tests/scope/025-scope_awaitAfterCancellation_error_handler.phpt @@ -0,0 +1,79 @@ +--TEST-- +Scope: awaitAfterCancellation() - with error handler +--FILE-- +spawn(function() { + echo "error coroutine started\n"; + + try { + suspend(); // Suspend to simulate work + } catch (\CancellationException $e) { + echo "coroutine cancelled\n"; + suspend(); + throw new \RuntimeException("Coroutine error after cancellation"); + } +}); + +$normal_coroutine = $scope->spawn(function() { + echo "normal coroutine started\n"; + suspend(); + suspend(); + echo "normal coroutine finished\n"; + return "normal_result"; +}); + +echo "spawned coroutines\n"; + +// Await after cancellation with error handler +$external = spawn(function() use ($scope) { + echo "external waiting with error handler\n"; + + // Cancel the scope + $scope->cancel(); + echo "scope cancel\n"; + suspend(); // Let cancellation propagate + + echo "awaitAfterCancellation with handler started\n"; + + $scope->awaitAfterCancellation( + function($error) { + echo "error handler called: {$error->getMessage()}\n"; + }, + timeout(10) + ); + + echo "awaitAfterCancellation with handler completed\n"; +}); + +await($external); + +echo "scope finished: " . ($scope->isFinished() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +spawned coroutines +error coroutine started +normal coroutine started +external waiting with error handler +scope cancel +coroutine cancelled +awaitAfterCancellation with handler started +error handler called: Coroutine error after cancellation +awaitAfterCancellation with handler completed +scope finished: true +end \ No newline at end of file diff --git a/tests/scope/026-scope_cancel_with_active_coroutines.phpt b/tests/scope/026-scope_cancel_with_active_coroutines.phpt new file mode 100644 index 0000000..5539a7c --- /dev/null +++ b/tests/scope/026-scope_cancel_with_active_coroutines.phpt @@ -0,0 +1,87 @@ +--TEST-- +Scope: cancel() - comprehensive cancellation with active coroutines +--FILE-- +spawn(function() { + echo "coroutine1 started\n"; + try { + suspend(); + suspend(); + echo "coroutine1 should not reach here\n"; + return "result1"; + } catch (\Async\CancellationException $e) { + echo "coroutine1 caught cancellation: " . $e->getMessage() . "\n"; + throw $e; + } +}); + +$coroutine2 = $scope->spawn(function() { + echo "coroutine2 started\n"; + try { + suspend(); + suspend(); + echo "coroutine2 should not reach here\n"; + return "result2"; + } catch (\Async\CancellationException $e) { + echo "coroutine2 caught cancellation: " . $e->getMessage() . "\n"; + throw $e; + } +}); + +echo "spawned coroutines\n"; + +// Let coroutines start +suspend(); + +echo "cancelling scope\n"; +$scope->cancel(new \Async\CancellationException("Custom cancellation message")); + +// Let cancellation propagate +suspend(); + +echo "verifying cancellation\n"; +echo "scope finished: " . ($scope->isFinished() ? "true" : "false") . "\n"; +echo "scope closed: " . ($scope->isClosed() ? "true" : "false") . "\n"; + +// Verify coroutines are cancelled +echo "coroutine1 cancelled: " . ($coroutine1->isCancelled() ? "true" : "false") . "\n"; +echo "coroutine2 cancelled: " . ($coroutine2->isCancelled() ? "true" : "false") . "\n"; + +// Try to spawn in cancelled scope (should fail) +try { + $scope->spawn(function() { + return "should_not_work"; + }); + echo "ERROR: Should not be able to spawn in closed scope\n"; +} catch (Async\AsyncException $e) { + echo "caught expected error: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +spawned coroutines +coroutine1 started +coroutine2 started +cancelling scope +coroutine1 caught cancellation: Custom cancellation message +coroutine2 caught cancellation: Custom cancellation message +verifying cancellation +scope finished: true +scope closed: true +coroutine1 cancelled: true +coroutine2 cancelled: true +caught expected error: %s +end \ No newline at end of file diff --git a/tests/scope/027-scope_exception_handlers_execution.phpt b/tests/scope/027-scope_exception_handlers_execution.phpt new file mode 100644 index 0000000..c9f405c --- /dev/null +++ b/tests/scope/027-scope_exception_handlers_execution.phpt @@ -0,0 +1,88 @@ +--TEST-- +Scope: exception handlers - actual execution and propagation +--FILE-- +setExceptionHandler(function($receivedScope, $coroutine, $exception) use ($scope, &$exceptions_handled) { + echo "exception handler called\n"; + echo "scope match: " . ($receivedScope === $scope ? "true" : "false") . "\n"; + echo "coroutine type: " . get_class($coroutine) . "\n"; + echo "exception message: " . $exception->getMessage() . "\n"; + $exceptions_handled[] = $exception->getMessage(); +}); + +// Spawn coroutines that will throw exceptions +$error_coroutine1 = $scope->spawn(function() { + echo "error coroutine1 started\n"; + suspend(); + throw new \RuntimeException("Error from coroutine1"); +}); + +$error_coroutine2 = $scope->spawn(function() { + echo "error coroutine2 started\n"; + suspend(); + throw new \InvalidArgumentException("Error from coroutine2"); +}); + +$normal_coroutine = $scope->spawn(function() { + echo "normal coroutine started\n"; + suspend(); + echo "normal coroutine finished\n"; + return "normal_result"; +}); + +echo "spawned coroutines\n"; + +// Let all coroutines run +suspend(); +suspend(); + +echo "waiting for completion\n"; +$normal_result = await($normal_coroutine); +echo "normal result: " . $normal_result . "\n"; + +echo "exceptions handled count: " . count($exceptions_handled) . "\n"; +foreach ($exceptions_handled as $msg) { + echo "handled: " . $msg . "\n"; +} + +echo "scope finished: " . ($scope->isFinished() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +spawned coroutines +error coroutine1 started +error coroutine2 started +normal coroutine started +exception handler called +scope match: true +coroutine type: Async\Coroutine +exception message: Error from coroutine1 +exception handler called +scope match: true +coroutine type: Async\Coroutine +exception message: Error from coroutine2 +normal coroutine finished +waiting for completion +normal result: normal_result +exceptions handled count: 2 +handled: Error from coroutine1 +handled: Error from coroutine2 +scope finished: true +end \ No newline at end of file diff --git a/tests/scope/028-scope_hierarchy_cancellation_basic.phpt b/tests/scope/028-scope_hierarchy_cancellation_basic.phpt new file mode 100644 index 0000000..ec94433 --- /dev/null +++ b/tests/scope/028-scope_hierarchy_cancellation_basic.phpt @@ -0,0 +1,94 @@ +--TEST-- +Basic scope hierarchy cancellation propagation + asNotSafely() +--FILE-- +asNotSafely(); + +// Create child scopes +$child1_scope = \Async\Scope::inherit($parent_scope); +$child2_scope = \Async\Scope::inherit($parent_scope); + +echo "scopes created\n"; + +// Spawn coroutines in each scope +$parent_coroutine = $parent_scope->spawn(function() { + echo "parent coroutine started\n"; + suspend(); + suspend(); + echo "parent coroutine should not complete\n"; + return "parent_result"; +}); + +$child1_coroutine = $child1_scope->spawn(function() { + echo "child1 coroutine started\n"; + suspend(); + suspend(); + echo "child1 coroutine should not complete\n"; + return "child1_result"; +}); + +$child2_coroutine = $child2_scope->spawn(function() { + echo "child2 coroutine started\n"; + suspend(); + suspend(); + echo "child2 coroutine should not complete\n"; + return "child2_result"; +}); + +echo "coroutines spawned\n"; + +// Let coroutines start +suspend(); + +// Check initial states +echo "parent scope cancelled: " . ($parent_scope->isCancelled() ? "true" : "false") . "\n"; +echo "child1 scope cancelled: " . ($child1_scope->isCancelled() ? "true" : "false") . "\n"; +echo "child2 scope cancelled: " . ($child2_scope->isCancelled() ? "true" : "false") . "\n"; + +// Cancel parent scope - should cascade to children +echo "cancelling parent scope\n"; +$parent_scope->cancel(new \Async\CancellationException("Parent cancelled")); + +// Let cancellation propagate +suspend(); + +// Check states after parent cancellation +echo "after parent cancel - parent scope cancelled: " . ($parent_scope->isCancelled() ? "true" : "false") . "\n"; +echo "after parent cancel - child1 scope cancelled: " . ($child1_scope->isCancelled() ? "true" : "false") . "\n"; +echo "after parent cancel - child2 scope cancelled: " . ($child2_scope->isCancelled() ? "true" : "false") . "\n"; + +// Check coroutine states +echo "parent coroutine cancelled: " . ($parent_coroutine->isCancelled() ? "true" : "false") . "\n"; +echo "child1 coroutine cancelled: " . ($child1_coroutine->isCancelled() ? "true" : "false") . "\n"; +echo "child2 coroutine cancelled: " . ($child2_coroutine->isCancelled() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +scopes created +coroutines spawned +parent coroutine started +child1 coroutine started +child2 coroutine started +parent scope cancelled: false +child1 scope cancelled: false +child2 scope cancelled: false +cancelling parent scope +after parent cancel - parent scope cancelled: true +after parent cancel - child1 scope cancelled: true +after parent cancel - child2 scope cancelled: true +parent coroutine cancelled: true +child1 coroutine cancelled: true +child2 coroutine cancelled: true +end \ No newline at end of file diff --git a/tests/scope/029-scope_complex_tree_cancellation.phpt b/tests/scope/029-scope_complex_tree_cancellation.phpt new file mode 100644 index 0000000..74ae830 --- /dev/null +++ b/tests/scope/029-scope_complex_tree_cancellation.phpt @@ -0,0 +1,135 @@ +--TEST-- +Complex scope tree cancellation with multi-level hierarchy +--FILE-- + child -> grandchild -> great-grandchild +$parent_scope = new \Async\Scope(); +$child_scope = \Async\Scope::inherit($parent_scope); +$grandchild_scope = \Async\Scope::inherit($child_scope); +$great_grandchild_scope = \Async\Scope::inherit($grandchild_scope); + +// Create sibling branch: parent -> sibling -> sibling_child +$sibling_scope = \Async\Scope::inherit($parent_scope); +$sibling_child_scope = \Async\Scope::inherit($sibling_scope); + +echo "complex scope tree created\n"; + +// Spawn coroutines in each scope +$scopes_and_coroutines = [ + 'parent' => [$parent_scope, null], + 'child' => [$child_scope, null], + 'grandchild' => [$grandchild_scope, null], + 'great_grandchild' => [$great_grandchild_scope, null], + 'sibling' => [$sibling_scope, null], + 'sibling_child' => [$sibling_child_scope, null] +]; + +foreach ($scopes_and_coroutines as $name => &$data) { + $scope = $data[0]; + $data[1] = $scope->spawn(function() use ($name) { + echo "$name coroutine started\n"; + suspend(); + suspend(); + suspend(); + echo "$name coroutine should not complete\n"; + return "{$name}_result"; + }); +} + +echo "all coroutines spawned\n"; + +// Let all coroutines start +suspend(); + +// Verify initial states (all should be false) +foreach ($scopes_and_coroutines as $name => $data) { + $scope = $data[0]; + echo "$name scope initially cancelled: " . ($scope->isCancelled() ? "true" : "false") . "\n"; +} + +// Cancel middle node (child_scope) - should cancel its descendants but not ancestors +echo "cancelling child scope (middle node)\n"; +$child_scope->cancel(new \Async\CancellationException("Child cancelled")); + +// Let cancellation propagate +suspend(); + +// Check cancellation propagation +echo "after child cancellation:\n"; +foreach ($scopes_and_coroutines as $name => $data) { + $scope = $data[0]; + echo "$name scope cancelled: " . ($scope->isCancelled() ? "true" : "false") . "\n"; +} + +// Now cancel parent - should cancel everything remaining +echo "cancelling parent scope (root)\n"; +$parent_scope->cancel(new \Async\CancellationException("Parent cancelled")); + +// Let cancellation propagate +suspend(); + +echo "after parent cancellation:\n"; +foreach ($scopes_and_coroutines as $name => $data) { + $scope = $data[0]; + echo "$name scope cancelled: " . ($scope->isCancelled() ? "true" : "false") . "\n"; +} + +// Verify all coroutines are cancelled +echo "coroutine cancellation results:\n"; +foreach ($scopes_and_coroutines as $name => $data) { + $coroutine = $data[1]; + if ($coroutine->isCancelled()) { + echo "$name coroutine cancelled: true\n"; + } +} + +echo "end\n"; + +?> +--EXPECTF-- +start +complex scope tree created +all coroutines spawned +parent coroutine started +child coroutine started +grandchild coroutine started +great_grandchild coroutine started +sibling coroutine started +sibling_child coroutine started +parent scope initially cancelled: false +child scope initially cancelled: false +grandchild scope initially cancelled: false +great_grandchild scope initially cancelled: false +sibling scope initially cancelled: false +sibling_child scope initially cancelled: false +cancelling child scope (middle node) +after child cancellation: +parent scope cancelled: false +child scope cancelled: true +grandchild scope cancelled: true +great_grandchild scope cancelled: true +sibling scope cancelled: false +sibling_child scope cancelled: false +cancelling parent scope (root) +after parent cancellation: +parent scope cancelled: true +child scope cancelled: true +grandchild scope cancelled: true +great_grandchild scope cancelled: true +sibling scope cancelled: true +sibling_child scope cancelled: true +coroutine cancellation results: +parent coroutine cancelled: true +child coroutine cancelled: true +grandchild coroutine cancelled: true +great_grandchild coroutine cancelled: true +sibling coroutine cancelled: true +sibling_child coroutine cancelled: true +end \ No newline at end of file diff --git a/tests/scope/030-scope_inheritance_cancellation_isolation.phpt b/tests/scope/030-scope_inheritance_cancellation_isolation.phpt new file mode 100644 index 0000000..5d2b6e5 --- /dev/null +++ b/tests/scope/030-scope_inheritance_cancellation_isolation.phpt @@ -0,0 +1,115 @@ +--TEST-- +Scope inheritance cancellation isolation (child cancellation should not affect parent) +--FILE-- +asNotSafely(); + +// Create multiple child scopes +$child1_scope = \Async\Scope::inherit($parent_scope); +$child2_scope = \Async\Scope::inherit($parent_scope); +$child3_scope = \Async\Scope::inherit($parent_scope); + +echo "parent and child scopes created\n"; + +// Spawn coroutines in all scopes +$parent_coroutine = $parent_scope->spawn(function() { + echo "parent coroutine started\n"; + suspend(); + suspend(); + echo "parent coroutine completed\n"; + return "parent_result"; +}); + +$child1_coroutine = $child1_scope->spawn(function() { + echo "child1 coroutine started\n"; + suspend(); + echo "child1 should not complete\n"; + return "child1_result"; +}); + +$child2_coroutine = $child2_scope->spawn(function() { + echo "child2 coroutine started\n"; + suspend(); + suspend(); + echo "child2 coroutine completed\n"; + return "child2_result"; +}); + +$child3_coroutine = $child3_scope->spawn(function() { + echo "child3 coroutine started\n"; + suspend(); + suspend(); + suspend(); + echo "child3 should not complete\n"; + return "child3_result"; +}); + +echo "all coroutines spawned\n"; + +// Let coroutines start +suspend(); + +// Cancel only child1 - should not affect parent or other children +echo "cancelling child1 scope only\n"; +$child1_scope->cancel(new \Async\CancellationException("Child1 cancelled")); + +suspend(); + +// Check isolation - only child1 should be cancelled +echo "after child1 cancellation:\n"; +echo "parent scope cancelled: " . ($parent_scope->isCancelled() ? "true" : "false") . "\n"; +echo "child1 scope cancelled: " . ($child1_scope->isCancelled() ? "true" : "false") . "\n"; +echo "child2 scope cancelled: " . ($child2_scope->isCancelled() ? "true" : "false") . "\n"; +echo "child3 scope cancelled: " . ($child3_scope->isCancelled() ? "true" : "false") . "\n"; + +// Continue execution for non-cancelled scopes +suspend(); + +// Cancel child3 as well +echo "cancelling child3 scope\n"; +$child3_scope->cancel(new \Async\CancellationException("Child3 cancelled")); + +// Let cancellation propagate +suspend(); + +echo "after child3 cancellation:\n"; +echo "parent scope cancelled: " . ($parent_scope->isCancelled() ? "true" : "false") . "\n"; +echo "child1 scope cancelled: " . ($child1_scope->isCancelled() ? "true" : "false") . "\n"; +echo "child2 scope cancelled: " . ($child2_scope->isCancelled() ? "true" : "false") . "\n"; +echo "child3 scope cancelled: " . ($child3_scope->isCancelled() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +parent and child scopes created +all coroutines spawned +parent coroutine started +child1 coroutine started +child2 coroutine started +child3 coroutine started +cancelling child1 scope only +after child1 cancellation: +parent scope cancelled: false +child1 scope cancelled: true +child2 scope cancelled: false +child3 scope cancelled: false +parent coroutine completed +child2 coroutine completed +cancelling child3 scope +after child3 cancellation: +parent scope cancelled: false +child1 scope cancelled: true +child2 scope cancelled: false +child3 scope cancelled: true +end \ No newline at end of file diff --git a/tests/scope/031-scope_concurrent_cancellation.phpt b/tests/scope/031-scope_concurrent_cancellation.phpt new file mode 100644 index 0000000..5de1867 --- /dev/null +++ b/tests/scope/031-scope_concurrent_cancellation.phpt @@ -0,0 +1,111 @@ +--TEST-- +Concurrent scope cancellation and race conditions +--FILE-- +asNotSafely(); +$scope2 = new \Async\Scope()->asNotSafely(); +$scope3 = new \Async\Scope()->asNotSafely(); + +echo "multiple scopes created\n"; + +// Spawn coroutines in each scope +$coroutine1 = $scope1->spawn(function() { + echo "coroutine1 started\n"; + suspend(); + suspend(); + echo "coroutine1 should not complete\n"; + return "result1"; +}); + +$coroutine2 = $scope2->spawn(function() { + echo "coroutine2 started\n"; + suspend(); + suspend(); + echo "coroutine2 should not complete\n"; + return "result2"; +}); + +$coroutine3 = $scope3->spawn(function() { + echo "coroutine3 started\n"; + suspend(); + suspend(); + echo "coroutine3 should not complete\n"; + return "result3"; +}); + +echo "coroutines spawned\n"; + +// Let coroutines start +suspend(); + +// Spawn concurrent cancellation operations +$canceller1 = spawn(function() use ($scope1) { + echo "canceller1 starting\n"; + $scope1->cancel(new \Async\CancellationException("Concurrent cancel 1")); +}); + +$canceller2 = spawn(function() use ($scope2) { + echo "canceller2 starting\n"; + $scope2->cancel(new \Async\CancellationException("Concurrent cancel 2")); +}); + +$canceller3 = spawn(function() use ($scope3) { + echo "canceller3 starting\n"; + $scope3->cancel(new \Async\CancellationException("Concurrent cancel 3")); +}); + +echo "cancellers spawned\n"; + +awaitAll([$canceller1, $canceller2, $canceller3]); + +echo "all cancellers completed\n"; + +// Verify all scopes are cancelled +echo "scope1 cancelled: " . ($scope1->isCancelled() ? "true" : "false") . "\n"; +echo "scope2 cancelled: " . ($scope2->isCancelled() ? "true" : "false") . "\n"; +echo "scope3 cancelled: " . ($scope3->isCancelled() ? "true" : "false") . "\n"; + +// Verify all coroutines are cancelled +$cancelled_count = 0; +foreach ([$coroutine1, $coroutine2, $coroutine3] as $index => $coroutine) { + if($coroutine->isCancelled()) { + $cancelled_count++; + echo "coroutine" . ($index + 1) . " cancelled: " . $coroutine->getException()->getMessage() . "\n"; + } else { + echo "coroutine" . ($index + 1) . " not cancelled\n"; + } +} + +echo "cancelled coroutines: $cancelled_count\n"; +echo "end\n"; + +?> +--EXPECTF-- +start +multiple scopes created +coroutines spawned +coroutine1 started +coroutine2 started +coroutine3 started +cancellers spawned +canceller1 starting +canceller2 starting +canceller3 starting +all cancellers completed +scope1 cancelled: true +scope2 cancelled: true +scope3 cancelled: true +coroutine1 cancelled: Concurrent cancel 1 +coroutine2 cancelled: Concurrent cancel 2 +coroutine3 cancelled: Concurrent cancel 3 +cancelled coroutines: 3 +end \ No newline at end of file diff --git a/tests/scope/032-scope_mixed_cancellation_sources.phpt b/tests/scope/032-scope_mixed_cancellation_sources.phpt new file mode 100644 index 0000000..a2426d9 --- /dev/null +++ b/tests/scope/032-scope_mixed_cancellation_sources.phpt @@ -0,0 +1,92 @@ +--TEST-- +Mixed cancellation sources: scope cancellation + individual coroutine cancellation +--FILE-- +asNotSafely(); + +// Spawn multiple coroutines in the same scope +$coroutine1 = $scope->spawn(function() { + echo "coroutine1 started\n"; + suspend(); + suspend(); + echo "coroutine1 should not complete\n"; + return "result1"; +}); + +$coroutine2 = $scope->spawn(function() { + echo "coroutine2 started\n"; + suspend(); + suspend(); + echo "coroutine2 should not complete\n"; + return "result2"; +}); + +$coroutine3 = $scope->spawn(function() { + echo "coroutine3 started\n"; + suspend(); + suspend(); + echo "coroutine3 should not complete\n"; + return "result3"; +}); + +echo "coroutines spawned in scope\n"; + +// Let coroutines start +suspend(); + +// Cancel individual coroutine first +echo "cancelling coroutine2 individually\n"; +$coroutine2->cancel(new \Async\CancellationException("Individual cancel")); + +// Let cancellation propagate +suspend(); + +// Check states after individual cancellation +echo "after individual cancel:\n"; +echo "scope cancelled: " . ($scope->isCancelled() ? "true" : "false") . "\n"; +echo "coroutine1 cancelled: " . ($coroutine1->isCancelled() ? "true" : "false") . "\n"; +echo "coroutine2 cancelled: " . ($coroutine2->isCancelled() ? "true" : "false") . "\n"; +echo "coroutine3 cancelled: " . ($coroutine3->isCancelled() ? "true" : "false") . "\n"; + +// Now cancel the entire scope +echo "cancelling entire scope\n"; +$scope->cancel(new \Async\CancellationException("Scope cancel")); + +suspend(); // Let cancellation propagate + +// Check states after scope cancellation +echo "after scope cancel:\n"; +echo "scope cancelled: " . ($scope->isCancelled() ? "true" : "false") . "\n"; +echo "coroutine1 cancelled: " . ($coroutine1->isCancelled() ? "true" : "false") . "\n"; +echo "coroutine2 cancelled: " . ($coroutine2->isCancelled() ? "true" : "false") . "\n"; +echo "coroutine3 cancelled: " . ($coroutine3->isCancelled() ? "true" : "false") . "\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +coroutines spawned in scope +coroutine1 started +coroutine2 started +coroutine3 started +cancelling coroutine2 individually +after individual cancel: +scope cancelled: false +coroutine1 cancelled: false +coroutine2 cancelled: true +coroutine3 cancelled: false +cancelling entire scope +after scope cancel: +scope cancelled: true +coroutine1 cancelled: true +coroutine2 cancelled: true +coroutine3 cancelled: true +end \ No newline at end of file diff --git a/tests/scope/033-scope_cancellation_finally_handlers.phpt b/tests/scope/033-scope_cancellation_finally_handlers.phpt new file mode 100644 index 0000000..a423ed2 --- /dev/null +++ b/tests/scope/033-scope_cancellation_finally_handlers.phpt @@ -0,0 +1,92 @@ +--TEST-- +Scope cancellation with finally handlers execution +--FILE-- +spawn(function() { + echo "coroutine with finally started\n"; + + $coroutine = \Async\currentCoroutine(); + + $coroutine->onFinally(function() { + echo "finally handler 1 executed\n"; + }); + + $coroutine->onFinally(function() { + echo "finally handler 2 executed\n"; + return "finally_cleanup"; + }); + + $coroutine->onFinally(function() { + echo "finally handler 3 executed\n"; + // This might throw during cancellation cleanup + throw new \RuntimeException("Finally handler error"); + }); + + suspend(); + echo "coroutine should not complete normally\n"; + return "normal_result"; +}); + +// Spawn coroutine in child scope with finally handlers +$child_scope = \Async\Scope::inherit($scope); +$child_coroutine = $child_scope->spawn(function() { + echo "child coroutine started\n"; + + $coroutine = \Async\currentCoroutine(); + + $coroutine->onFinally(function() { + echo "child finally handler executed\n"; + }); + + suspend(); + echo "child should not complete\n"; + return "child_result"; +}); + +echo "coroutines with finally handlers spawned\n"; + +// Let coroutines start +suspend(); + +// Add scope-level finally handler +$scope->onFinally(function() { + echo "scope finally handler executed\n"; +}); + +echo "scope finally handler added\n"; + +// Cancel the parent scope - should trigger all finally handlers +echo "cancelling parent scope\n"; +$scope->cancel(new \Async\CancellationException("Scope cancelled with finally")); + +// Let cancellation propagate +suspend(); + +echo "end\n"; + +?> +--EXPECTF-- +start +coroutines with finally handlers spawned +coroutine with finally started +child coroutine started +scope finally handler added +cancelling parent scope +finally handler 1 executed +finally handler 2 executed +finally handler 3 executed +child finally handler executed +scope finally handler executed + +Fatal error: Uncaught RuntimeException:%s +%a \ No newline at end of file diff --git a/tests/scope/034-scope_cancellation_double_error.phpt b/tests/scope/034-scope_cancellation_double_error.phpt new file mode 100644 index 0000000..5a4f282 --- /dev/null +++ b/tests/scope/034-scope_cancellation_double_error.phpt @@ -0,0 +1,80 @@ +--TEST-- +Scope cancellation with double-exception case in finally handlers execution +--DESCRIPTION-- +This test triggers a double-exception case: first in the coroutine, and then in the onFinally handler. +--FILE-- +spawn(function() { + echo "coroutine with finally started\n"; + + $coroutine = \Async\currentCoroutine(); + + $coroutine->onFinally(function() { + echo "finally handler 3 executed\n"; + // This might throw during cancellation cleanup + throw new \RuntimeException("Finally handler error"); + }); + + suspend(); + echo "coroutine should not complete normally\n"; + return "normal_result"; +}); + +// Spawn coroutine in child scope with finally handlers +$child_scope = \Async\Scope::inherit($scope); +$child_coroutine = $child_scope->spawn(function() { + echo "child coroutine started\n"; + + $coroutine = \Async\currentCoroutine(); + some_function(); +}); + +echo "coroutines with finally handlers spawned\n"; + +// Let coroutines start +suspend(); + +// Add scope-level finally handler +$scope->onFinally(function() { + echo "scope finally handler executed\n"; +}); + +echo "scope finally handler added\n"; + +// Cancel the parent scope - should trigger all finally handlers +echo "cancelling parent scope\n"; +$scope->cancel(new \Async\CancellationException("Scope cancelled with finally")); + +// Let cancellation propagate +suspend(); + +echo "end\n"; + +?> +--EXPECTF-- +start +coroutines with finally handlers spawned +coroutine with finally started +child coroutine started +finally handler 3 executed + +Fatal error: Uncaught Error: Call to undefined function some_function() in %s:%d +Stack trace: +#0 [internal function]: {closure:%s:%d}() +#1 {main} + +Next RuntimeException: Finally handler error in %s:%d +Stack trace: +#0 [internal function]: {closure:{closure:%s:%d}:%d}(Object(Async\Coroutine)) +#1 {main} + thrown in %s on line %d \ No newline at end of file diff --git a/tests/spawn/016-spawn_invalid_scope_provider.phpt b/tests/spawn/016-spawn_invalid_scope_provider.phpt new file mode 100644 index 0000000..7b8a631 --- /dev/null +++ b/tests/spawn/016-spawn_invalid_scope_provider.phpt @@ -0,0 +1,35 @@ +--TEST-- +spawnWith() - invalid scope provider type error +--FILE-- +getMessage() . "\n"; +} catch (Throwable $e) { + echo "Caught exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +Caught exception: TypeError: InvalidTypeScopeProvider::provideScope(): Return value must be of type ?Async\Scope, string returned +end \ No newline at end of file diff --git a/tests/spawn/017-spawn_closed_scope_error.phpt b/tests/spawn/017-spawn_closed_scope_error.phpt new file mode 100644 index 0000000..9dad2c5 --- /dev/null +++ b/tests/spawn/017-spawn_closed_scope_error.phpt @@ -0,0 +1,61 @@ +--TEST-- +spawn() - error when spawning in closed scope +--FILE-- +dispose(); +echo "Scope disposed\n"; + +try { + $coroutine = $scope->spawn(function() { + return "test"; + }); + echo "ERROR: Should have thrown exception\n"; +} catch (Async\AsyncException $e) { + echo "Caught expected error: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "Caught exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +// Test 2: Verify scope is actually closed +echo "Scope is closed: " . ($scope->isClosed() ? "true" : "false") . "\n"; +echo "Scope is finished: " . ($scope->isFinished() ? "true" : "false") . "\n"; + +// Test 3: Spawn in safely disposed scope should also fail +$scope2 = \Async\Scope::inherit(); +$scope2->disposeSafely(); +echo "Scope safely disposed\n"; + +try { + $coroutine2 = $scope2->spawn(function() { + return "test2"; + }); + echo "ERROR: Should have thrown exception\n"; +} catch (Async\AsyncException $e) { + echo "Caught expected error for safely disposed: " . $e->getMessage() . "\n"; +} catch (Throwable $e) { + echo "Caught exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +Scope created +Scope disposed +Caught expected error: Cannot spawn a coroutine in a closed scope +Scope is closed: true +Scope is finished: true +Scope safely disposed +Caught expected error for safely disposed: Cannot spawn a coroutine in a closed scope +end \ No newline at end of file diff --git a/tests/spawn/018-spawn_scope_provider_exception.phpt b/tests/spawn/018-spawn_scope_provider_exception.phpt new file mode 100644 index 0000000..a80e540 --- /dev/null +++ b/tests/spawn/018-spawn_scope_provider_exception.phpt @@ -0,0 +1,35 @@ +--TEST-- +spawnWith() - scope provider throws exception +--FILE-- +getMessage() . "\n"; +} catch (Throwable $e) { + echo "Caught exception: " . get_class($e) . ": " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Caught provider exception: Provider error +end \ No newline at end of file diff --git a/tests/spawn/019-spawn_scope_provider_null.phpt b/tests/spawn/019-spawn_scope_provider_null.phpt new file mode 100644 index 0000000..cee9d40 --- /dev/null +++ b/tests/spawn/019-spawn_scope_provider_null.phpt @@ -0,0 +1,35 @@ +--TEST-- +spawnWith() - scope provider returns null (valid case) +--FILE-- +getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +Null provider result: success +end \ No newline at end of file diff --git a/tests/spawnWith/001-spawnWith_basic.phpt b/tests/spawnWith/001-spawnWith_basic.phpt new file mode 100644 index 0000000..e549d3e --- /dev/null +++ b/tests/spawnWith/001-spawnWith_basic.phpt @@ -0,0 +1,32 @@ +--TEST-- +Async\spawnWith: basic usage with Scope +--FILE-- + +--EXPECT-- +start +spawned coroutine +coroutine executed +result: test result +end \ No newline at end of file diff --git a/tests/spawnWith/002-spawnWith_with_arguments.phpt b/tests/spawnWith/002-spawnWith_with_arguments.phpt new file mode 100644 index 0000000..b81e90e --- /dev/null +++ b/tests/spawnWith/002-spawnWith_with_arguments.phpt @@ -0,0 +1,29 @@ +--TEST-- +Async\spawnWith: with arguments +--FILE-- + +--EXPECT-- +start +arguments: 10, 20, 30 +result: 60 +end \ No newline at end of file diff --git a/tests/spawnWith/003-spawnWith_return_coroutine.phpt b/tests/spawnWith/003-spawnWith_return_coroutine.phpt new file mode 100644 index 0000000..5ec1bfd --- /dev/null +++ b/tests/spawnWith/003-spawnWith_return_coroutine.phpt @@ -0,0 +1,27 @@ +--TEST-- +Async\spawnWith: returns Coroutine instance +--FILE-- +getId())); + +$result = await($coroutine); +var_dump($result); + +?> +--EXPECT-- +bool(true) +bool(true) +string(4) "test" \ No newline at end of file diff --git a/tests/spawnWith/004-spawnWith_custom_scope_provider.phpt b/tests/spawnWith/004-spawnWith_custom_scope_provider.phpt new file mode 100644 index 0000000..0493cf1 --- /dev/null +++ b/tests/spawnWith/004-spawnWith_custom_scope_provider.phpt @@ -0,0 +1,49 @@ +--TEST-- +Async\spawnWith: custom ScopeProvider implementation +--FILE-- +scope = new Scope(); + echo "CustomScopeProvider created\n"; + } + + public function provideScope(): ?Scope + { + echo "provideScope called\n"; + return $this->scope; + } +} + +echo "start\n"; + +$provider = new CustomScopeProvider(); + +$coroutine = spawnWith($provider, function() { + echo "coroutine executed\n"; + return "custom provider result"; +}); + +$result = await($coroutine); +echo "result: $result\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +CustomScopeProvider created +provideScope called +coroutine executed +result: custom provider result +end \ No newline at end of file diff --git a/tests/spawnWith/005-spawnWith_null_scope_provider.phpt b/tests/spawnWith/005-spawnWith_null_scope_provider.phpt new file mode 100644 index 0000000..695dd6a --- /dev/null +++ b/tests/spawnWith/005-spawnWith_null_scope_provider.phpt @@ -0,0 +1,40 @@ +--TEST-- +Async\spawnWith: ScopeProvider returning null scope +--FILE-- + +--EXPECT-- +start +returning null scope +coroutine executed +result: null scope result +end \ No newline at end of file diff --git a/tests/spawnWith/006-spawnWith_inherited_scope.phpt b/tests/spawnWith/006-spawnWith_inherited_scope.phpt new file mode 100644 index 0000000..874907f --- /dev/null +++ b/tests/spawnWith/006-spawnWith_inherited_scope.phpt @@ -0,0 +1,30 @@ +--TEST-- +Async\spawnWith: with inherited scope +--FILE-- + +--EXPECT-- +start +coroutine in child scope +result: inherited scope result +end \ No newline at end of file diff --git a/tests/spawnWith/007-spawnWith_spawn_strategy.phpt b/tests/spawnWith/007-spawnWith_spawn_strategy.phpt new file mode 100644 index 0000000..068fbc4 --- /dev/null +++ b/tests/spawnWith/007-spawnWith_spawn_strategy.phpt @@ -0,0 +1,61 @@ +--TEST-- +Async\spawnWith: SpawnStrategy with hooks +--FILE-- +scope = new Scope(); + } + + public function provideScope(): ?Scope + { + echo "provideScope called\n"; + return $this->scope; + } + + public function beforeCoroutineEnqueue(Coroutine $coroutine, Scope $scope): array + { + echo "beforeCoroutineEnqueue called with coroutine ID: " . $coroutine->getId() . "\n"; + return []; + } + + public function afterCoroutineEnqueue(Coroutine $coroutine, Scope $scope): void + { + echo "afterCoroutineEnqueue called with coroutine ID: " . $coroutine->getId() . "\n"; + } +} + +echo "start\n"; + +$strategy = new TestSpawnStrategy(); + +$coroutine = spawnWith($strategy, function() { + echo "coroutine executed\n"; + return "strategy result"; +}); + +$result = await($coroutine); +echo "result: $result\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +provideScope called +beforeCoroutineEnqueue called with coroutine ID: %d +afterCoroutineEnqueue called with coroutine ID: %d +coroutine executed +result: strategy result +end \ No newline at end of file diff --git a/tests/spawnWith/008-spawnWith_strategy_hook_order.phpt b/tests/spawnWith/008-spawnWith_strategy_hook_order.phpt new file mode 100644 index 0000000..b61fb1c --- /dev/null +++ b/tests/spawnWith/008-spawnWith_strategy_hook_order.phpt @@ -0,0 +1,61 @@ +--TEST-- +Async\spawnWith: SpawnStrategy hook execution order +--FILE-- +scope = new Scope(); + } + + public function provideScope(): ?Scope + { + echo "1. provideScope\n"; + return $this->scope; + } + + public function beforeCoroutineEnqueue(Coroutine $coroutine, Scope $scope): array + { + echo "2. beforeCoroutineEnqueue\n"; + return []; + } + + public function afterCoroutineEnqueue(Coroutine $coroutine, Scope $scope): void + { + echo "4. afterCoroutineEnqueue\n"; + } +} + +echo "start\n"; + +$strategy = new OrderTestStrategy(); + +$coroutine = spawnWith($strategy, function() { + echo "3. coroutine executed\n"; + return "order test"; +}); + +$result = await($coroutine); +echo "result: $result\n"; + +echo "end\n"; + +?> +--EXPECT-- +start +1. provideScope +2. beforeCoroutineEnqueue +4. afterCoroutineEnqueue +3. coroutine executed +result: order test +end \ No newline at end of file diff --git a/tests/spawnWith/009-spawnWith_strategy_exception.phpt b/tests/spawnWith/009-spawnWith_strategy_exception.phpt new file mode 100644 index 0000000..0096061 --- /dev/null +++ b/tests/spawnWith/009-spawnWith_strategy_exception.phpt @@ -0,0 +1,62 @@ +--TEST-- +Async\spawnWith: SpawnStrategy with exception in coroutine +--FILE-- +scope = new Scope(); + } + + public function provideScope(): ?Scope + { + return $this->scope; + } + + public function beforeCoroutineEnqueue(Coroutine $coroutine, Scope $scope): array + { + echo "before coroutine enqueue\n"; + return []; + } + + public function afterCoroutineEnqueue(Coroutine $coroutine, Scope $scope): void + { + echo "after coroutine enqueue\n"; + } +} + +echo "start\n"; + +$strategy = new ExceptionTestStrategy(); + +$coroutine = spawnWith($strategy, function() { + echo "coroutine start\n"; + throw new RuntimeException("test exception from coroutine"); +}); + +try { + await($coroutine); +} catch (RuntimeException $e) { + echo "caught exception: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +before coroutine enqueue +after coroutine enqueue +coroutine start +caught exception: test exception from coroutine +end \ No newline at end of file diff --git a/tests/spawnWith/010-spawnWith_invalid_provider.phpt b/tests/spawnWith/010-spawnWith_invalid_provider.phpt new file mode 100644 index 0000000..6e16ded --- /dev/null +++ b/tests/spawnWith/010-spawnWith_invalid_provider.phpt @@ -0,0 +1,39 @@ +--TEST-- +Async\spawnWith: invalid provider parameter +--FILE-- +getMessage() . "\n"; +} + +// Test with array +try { + spawnWith([], function() {}); +} catch (TypeError $e) { + echo "caught TypeError for array\n"; +} + +// Test with stdClass +try { + spawnWith(new stdClass(), function() {}); +} catch (TypeError $e) { + echo "caught TypeError for stdClass\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +caught TypeError for string: %s +caught TypeError for array +caught TypeError for stdClass +end \ No newline at end of file diff --git a/tests/spawnWith/011-spawnWith_missing_parameters.phpt b/tests/spawnWith/011-spawnWith_missing_parameters.phpt new file mode 100644 index 0000000..07c37ef --- /dev/null +++ b/tests/spawnWith/011-spawnWith_missing_parameters.phpt @@ -0,0 +1,31 @@ +--TEST-- +Async\spawnWith: missing required parameters +--FILE-- +getMessage() . "\n"; +} + +// Test with only provider +try { + spawnWith(new Async\Scope()); +} catch (ArgumentCountError $e) { + echo "caught ArgumentCountError for missing callable\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +caught ArgumentCountError for no params: %s +caught ArgumentCountError for missing callable +end \ No newline at end of file diff --git a/tests/spawnWith/012-spawnWith_invalid_callable.phpt b/tests/spawnWith/012-spawnWith_invalid_callable.phpt new file mode 100644 index 0000000..5faff3e --- /dev/null +++ b/tests/spawnWith/012-spawnWith_invalid_callable.phpt @@ -0,0 +1,42 @@ +--TEST-- +Async\spawnWith: invalid callable parameter +--FILE-- +getMessage() . "\n"; +} + +// Test with array (not callable) +try { + spawnWith($scope, []); +} catch (TypeError $e) { + echo "caught TypeError for array\n"; +} + +// Test with null +try { + spawnWith($scope, null); +} catch (TypeError $e) { + echo "caught TypeError for null\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +caught TypeError for string: %s +caught TypeError for array +caught TypeError for null +end \ No newline at end of file