diff --git a/CHANGELOG.md b/CHANGELOG.md index 6647629..317038c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,23 @@ All notable changes to the Async extension for PHP will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.5.0] - 2025-10-31 +## [0.5.0] - 2025-12-31 ### Added +- **Fiber Support**: Full integration of PHP Fibers with TrueAsync coroutine system + - `Fiber::suspend()` and `Fiber::resume()` work in async scheduler context + - `Fiber::getCoroutine()` method to access fiber's coroutine + - Fiber status methods (isStarted, isSuspended, isRunning, isTerminated) + - Support for nested fibers and fiber-coroutine interactions + - Comprehensive test coverage for all fiber scenarios - **TrueAsync API**: Added `ZEND_ASYNC_SCHEDULER_LAUNCH()` macro for scheduler initialization +- **TrueAsync API**: Updated to version 0.8.0 with fiber support + +### Fixed +- **Critical GC Bug**: Fixed garbage collection crash during coroutine cancellation when exception occurs in main coroutine while GC is running +- Fixed double free in `zend_fiber_object_destroy()` +- Fixed `stream_select()` for `timeout == NULL` case in async context +- Fixed fiber memory leaks and improved GC logic ### Changed - **Deadlock Detection**: Replaced warnings with structured exception handling diff --git a/coroutine.c b/coroutine.c index 974fc73..56a7ede 100644 --- a/coroutine.c +++ b/coroutine.c @@ -343,7 +343,10 @@ zend_coroutine_t *async_new_coroutine(zend_async_scope_t *scope) { zend_object *object = coroutine_object_create(async_ce_coroutine); - if (UNEXPECTED(EG(exception))) { + if (UNEXPECTED(object == NULL || EG(exception))) { + if (object != NULL) { + zend_object_release(object); + } return NULL; } diff --git a/coroutine.h b/coroutine.h index 8eacb62..fdc933a 100644 --- a/coroutine.h +++ b/coroutine.h @@ -17,6 +17,7 @@ #define COROUTINE_H #include "php_async_api.h" +#include #include /* Fiber context structure for pooling */ diff --git a/libuv_reactor.c b/libuv_reactor.c index c1bb093..9cfd0b9 100644 --- a/libuv_reactor.c +++ b/libuv_reactor.c @@ -168,12 +168,12 @@ bool libuv_reactor_execute(bool no_wait) { // OPTIMIZATION: Skip uv_run() if no libuv handles to avoid unnecessary clock_gettime() calls if (!uv_loop_alive(UVLOOP)) { - return ZEND_ASYNC_ACTIVE_EVENT_COUNT > 0; + return false; } const bool has_handles = uv_run(UVLOOP, no_wait ? UV_RUN_NOWAIT : UV_RUN_ONCE); - return ZEND_ASYNC_ACTIVE_EVENT_COUNT > 0 || has_handles; + return has_handles && ZEND_ASYNC_ACTIVE_EVENT_COUNT > 0; } /* }}} */ diff --git a/scheduler.c b/scheduler.c index c661734..3a04678 100644 --- a/scheduler.c +++ b/scheduler.c @@ -526,6 +526,47 @@ static bool resolve_deadlocks(void) return false; } + // + // Let’s count the number of coroutine-fibers that are in the YIELD state. + // This state differs from the regular Suspended state in that + // the Fiber has transferred control back to the parent coroutine. + // + zend_long fiber_coroutines_count = 0; + + ZEND_HASH_FOREACH_VAL(&ASYNC_G(coroutines), value) { + const zend_coroutine_t *coroutine = (zend_coroutine_t *) Z_PTR_P(value); + + if (ZEND_COROUTINE_IS_FIBER(coroutine) + && ZEND_COROUTINE_IS_YIELD(coroutine) + && coroutine->extended_data != NULL) { + fiber_coroutines_count++; + } + } + ZEND_HASH_FOREACH_END(); + + // + // If all coroutines are fiber coroutines in the SUSPENDED state, + // we can simply cancel them without creating a deadlock exception. + // + if (fiber_coroutines_count == real_coroutines) { + + ZEND_HASH_FOREACH_VAL(&ASYNC_G(coroutines), value) { + zend_coroutine_t *coroutine = (zend_coroutine_t *) Z_PTR_P(value); + + if (ZEND_COROUTINE_IS_FIBER(coroutine) + && ZEND_COROUTINE_IS_YIELD(coroutine) + && coroutine->extended_data != NULL) { + ZEND_ASYNC_CANCEL(coroutine, zend_create_graceful_exit(), true); + + if (UNEXPECTED(EG(exception) != NULL)) { + return true; + } + } + } + ZEND_HASH_FOREACH_END(); + return false; + } + // Create deadlock exception to be set as exit_exception zend_object *deadlock_exception = async_new_exception(async_ce_deadlock_error, "Deadlock detected: no active coroutines, %u coroutines in waiting", real_coroutines); @@ -552,7 +593,7 @@ static bool resolve_deadlocks(void) ZEND_ASYNC_CANCEL( &coroutine->coroutine, async_new_exception(async_ce_cancellation_exception, "Deadlock detected"), true); - if (EG(exception) != NULL) { + if (UNEXPECTED(EG(exception) != NULL)) { return true; } } @@ -1025,6 +1066,16 @@ bool async_scheduler_coroutine_enqueue(zend_coroutine_t *coroutine) // save the filename and line number where the coroutine was created zend_apply_current_filename_and_line(&coroutine->filename, &coroutine->lineno); + // Notify scope that a new coroutine has been enqueued + zend_async_scope_t *scope = coroutine->scope; + + if (UNEXPECTED(scope == NULL)) { + // throw error if the coroutine has no scope + coroutine->waker->status = ZEND_ASYNC_WAKER_NO_STATUS; + async_throw_error("The coroutine has no scope assigned"); + return false; + } + if (UNEXPECTED(zend_hash_index_add_ptr(&ASYNC_G(coroutines), ((async_coroutine_t *)coroutine)->std.handle, coroutine) == NULL)) { coroutine->waker->status = ZEND_ASYNC_WAKER_IGNORED; async_throw_error("Failed to add coroutine to the list"); @@ -1033,8 +1084,6 @@ bool async_scheduler_coroutine_enqueue(zend_coroutine_t *coroutine) ZEND_ASYNC_INCREASE_COROUTINE_COUNT; - // Notify scope that a new coroutine has been enqueued - zend_async_scope_t *scope = coroutine->scope; scope->after_coroutine_enqueue(coroutine, scope); if (UNEXPECTED(EG(exception))) { coroutine->waker->status = ZEND_ASYNC_WAKER_IGNORED; diff --git a/tests/edge_cases/007-fiber_first_then_spawn.phpt b/tests/edge_cases/007-fiber_first_then_spawn.phpt index 9270cfb..294f249 100644 --- a/tests/edge_cases/007-fiber_first_then_spawn.phpt +++ b/tests/edge_cases/007-fiber_first_then_spawn.phpt @@ -46,5 +46,11 @@ echo "Test completed\n"; Test: Fiber first, then spawn Starting Fiber Inside Fiber -Async exception caught: Cannot spawn a coroutine when async is disabled +Fiber attempting to continue after spawn +Inside spawned coroutine from Fiber +Fiber suspended with: fiber suspended +Resuming Fiber +Coroutine completed +Fiber resumed +Fiber returned: fiber done Test completed \ No newline at end of file diff --git a/tests/edge_cases/008-spawn_first_then_fiber.phpt b/tests/edge_cases/008-spawn_first_then_fiber.phpt index fb6f8f8..86fbc59 100644 --- a/tests/edge_cases/008-spawn_first_then_fiber.phpt +++ b/tests/edge_cases/008-spawn_first_then_fiber.phpt @@ -55,7 +55,14 @@ echo "Test completed\n"; --EXPECTF-- Test: Spawn first, then Fiber Coroutine spawned, now creating Fiber -Error caught: Cannot create a fiber while an True Async is active -Test completed +Starting Fiber Coroutine started -Coroutine resumed \ No newline at end of file +Inside Fiber - this should conflict with active scheduler +Coroutine resumed +Fiber suspended with: fiber suspended +Resuming Fiber +Fiber resumed +Fiber completed with: fiber done +Getting coroutine result +Coroutine completed with: coroutine result +Test completed \ No newline at end of file diff --git a/tests/edge_cases/009-fiber_spawn_destructor.phpt b/tests/edge_cases/009-fiber_spawn_destructor.phpt index b90e845..aa545ac 100644 --- a/tests/edge_cases/009-fiber_spawn_destructor.phpt +++ b/tests/edge_cases/009-fiber_spawn_destructor.phpt @@ -100,14 +100,18 @@ Created: SpawnInDestructor Starting some async operations Unsetting objects to trigger destructors Destructing: FiberInDestructor -Error in destructor: Cannot create a fiber while an True Async is active +Starting fiber in destructor +Main coroutine running +Fiber running in destructor +Main coroutine resumed +Fiber suspended with: destructor fiber +Fiber resumed in destructor +Fiber completed with: destructor done Destructor finished: FiberInDestructor Destructing: SpawnInDestructor Spawning coroutine in destructor Waiting for coroutine in destructor -Main coroutine running Coroutine running in destructor -Main coroutine resumed Coroutine resumed in destructor Coroutine completed with: destructor coroutine done Destructor finished: SpawnInDestructor diff --git a/tests/edge_cases/011-gc_include_symtable_double_delref.phpt b/tests/edge_cases/011-gc_include_symtable_double_delref.phpt new file mode 100644 index 0000000..b10a95e --- /dev/null +++ b/tests/edge_cases/011-gc_include_symtable_double_delref.phpt @@ -0,0 +1,53 @@ +--TEST-- +GC with include in suspended coroutine - symTable double DELREF +--FILE-- +self = $obj; + } + + // Suspend - coroutine is now in suspended state + suspend(); + + // Include inherits parent symTable + // Bug: GC may add same variables twice -> double DELREF + include __DIR__ . '/011-gc_include_symtable_double_delref_included.inc'; + + echo "parent1: {$parent1}\n"; + echo "parent2: {$parent2}\n"; + echo "included: {$included}\n"; + + return "done"; + }); + + $result = await($coroutine); + echo "result: {$result}\n"; + +} catch (Error $e) { + echo "Error: " . $e->getMessage() . "\n"; +} + +echo "OK\n"; +gc_collect_cycles(); +?> +--EXPECTF-- +parent1: value1 +parent2: value2 +included: included_value +result: done +OK diff --git a/tests/edge_cases/011-gc_include_symtable_double_delref_included.inc b/tests/edge_cases/011-gc_include_symtable_double_delref_included.inc new file mode 100644 index 0000000..b3cc8c3 --- /dev/null +++ b/tests/edge_cases/011-gc_include_symtable_double_delref_included.inc @@ -0,0 +1,3 @@ +start(); + echo "Fiber returned: " . $result . "\n"; + + return "coroutine result"; +}); + +$result = await($coroutine); +echo "Coroutine completed with: " . $result . "\n"; + +echo "Test completed\n"; +?> +--EXPECT-- +Test: Fiber creation with active async scheduler +Coroutine started +Starting fiber +Fiber executing +Fiber returned: fiber result +Coroutine completed with: coroutine result +Test completed \ No newline at end of file diff --git a/tests/fiber/002-fiber_simple_return.phpt b/tests/fiber/002-fiber_simple_return.phpt new file mode 100644 index 0000000..c98aad3 --- /dev/null +++ b/tests/fiber/002-fiber_simple_return.phpt @@ -0,0 +1,30 @@ +--TEST-- +Fiber with simple return without suspend +--FILE-- +start(); + echo "Got: " . $result . "\n"; + + return "coroutine done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Fiber simple return without suspend +Fiber executing +Got: result from fiber +Test completed diff --git a/tests/fiber/003-fiber_one_suspend_resume.phpt b/tests/fiber/003-fiber_one_suspend_resume.phpt new file mode 100644 index 0000000..57f5dab --- /dev/null +++ b/tests/fiber/003-fiber_one_suspend_resume.phpt @@ -0,0 +1,37 @@ +--TEST-- +One suspend/resume cycle +--FILE-- +start(); + echo "Fiber suspended with: " . $suspended . "\n"; + + $result = $fiber->resume("resume value"); + echo "Fiber returned: " . $result . "\n"; + + return "complete"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: One suspend/resume cycle +Before suspend +Fiber suspended with: suspended +After resume, got: resume value +Fiber returned: done +Test completed diff --git a/tests/fiber/004-fiber_multiple_suspends.phpt b/tests/fiber/004-fiber_multiple_suspends.phpt new file mode 100644 index 0000000..6bfb8a8 --- /dev/null +++ b/tests/fiber/004-fiber_multiple_suspends.phpt @@ -0,0 +1,45 @@ +--TEST-- +Multiple suspend/resume cycles +--FILE-- +start() . "\n"; + echo "R2: " . $fiber->resume("resume-1") . "\n"; + echo "R3: " . $fiber->resume("resume-2") . "\n"; + echo "R4: " . $fiber->resume("resume-3") . "\n"; + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Multiple suspend/resume cycles +R1: suspend-1 +Got: resume-1 +R2: suspend-2 +Got: resume-2 +R3: suspend-3 +Got: resume-3 +R4: final +Test completed diff --git a/tests/fiber/005-fiber_nested_spawn.phpt b/tests/fiber/005-fiber_nested_spawn.phpt new file mode 100644 index 0000000..d282742 --- /dev/null +++ b/tests/fiber/005-fiber_nested_spawn.phpt @@ -0,0 +1,45 @@ +--TEST-- +Nested spawn inside Fiber +--FILE-- +start(); + echo "Fiber completed: " . $result . "\n"; + + return "outer done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Nested spawn inside Fiber +Fiber: spawning inner coroutine +Inner: started +Inner: resumed +Fiber: inner returned: inner result +Fiber completed: fiber done +Test completed diff --git a/tests/fiber/006-fiber_nested_fibers.phpt b/tests/fiber/006-fiber_nested_fibers.phpt new file mode 100644 index 0000000..155b7e7 --- /dev/null +++ b/tests/fiber/006-fiber_nested_fibers.phpt @@ -0,0 +1,48 @@ +--TEST-- +Nested Fibers (Fiber inside Fiber) +--FILE-- +start(); + echo "Inner suspended: " . $val . "\n"; + + $result = $inner->resume(); + echo "Inner result: " . $result . "\n"; + + return "outer done"; + }); + + $result = $outer->start(); + echo "Outer result: " . $result . "\n"; + + return "complete"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Nested Fibers +Outer fiber started +Inner fiber started +Inner suspended: inner suspend +Inner fiber resumed +Inner result: inner done +Outer result: outer done +Test completed diff --git a/tests/fiber/007-fiber_concurrent_fibers.phpt b/tests/fiber/007-fiber_concurrent_fibers.phpt new file mode 100644 index 0000000..969c110 --- /dev/null +++ b/tests/fiber/007-fiber_concurrent_fibers.phpt @@ -0,0 +1,60 @@ +--TEST-- +Multiple concurrent Fibers +--FILE-- +start(); + $fiber2->start(); + + echo "Resume F2\n"; + $r2 = $fiber2->resume(); + echo "F2 result: " . $r2 . "\n"; + + echo "Resume F1 (1)\n"; + $fiber1->resume(); + + echo "Resume F1 (2)\n"; + $r1 = $fiber1->resume(); + echo "F1 result: " . $r1 . "\n"; + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Concurrent Fibers +F1: step 1 +F2: step 1 +Resume F2 +F2: step 2 +F2 result: fiber2 done +Resume F1 (1) +F1: step 2 +Resume F1 (2) +F1: step 3 +F1 result: fiber1 done +Test completed diff --git a/tests/fiber/008-fiber_exception_natural.phpt b/tests/fiber/008-fiber_exception_natural.phpt new file mode 100644 index 0000000..c45d2bf --- /dev/null +++ b/tests/fiber/008-fiber_exception_natural.phpt @@ -0,0 +1,34 @@ +--TEST-- +Natural exception inside Fiber +--FILE-- +start(); + echo "Should not reach here\n"; + } catch (Exception $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Natural exception in Fiber +Before exception +Caught: Fiber error +Test completed diff --git a/tests/fiber/009-fiber_throw_method.phpt b/tests/fiber/009-fiber_throw_method.phpt new file mode 100644 index 0000000..ff840de --- /dev/null +++ b/tests/fiber/009-fiber_throw_method.phpt @@ -0,0 +1,44 @@ +--TEST-- +Fiber.throw() with exception handling +--FILE-- +getMessage() . "\n"; + return "recovered"; + } + + return "normal"; + }); + + $val = $fiber->start(); + echo "Suspended: " . $val . "\n"; + + $result = $fiber->throw(new Exception("thrown error")); + echo "Result: " . $result . "\n"; + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Fiber throw method +Before suspend +Suspended: waiting +Caught in fiber: thrown error +Result: recovered +Test completed diff --git a/tests/fiber/010-fiber_exception_propagation.phpt b/tests/fiber/010-fiber_exception_propagation.phpt new file mode 100644 index 0000000..b8bd5e5 --- /dev/null +++ b/tests/fiber/010-fiber_exception_propagation.phpt @@ -0,0 +1,41 @@ +--TEST-- +Exception propagation from Fiber to coroutine +--FILE-- +start(); + echo "Got: " . $val . "\n"; + + $fiber->resume("resume"); + echo "Should not print\n"; + } catch (RuntimeException $e) { + echo "Caught in coroutine: " . $e->getMessage() . "\n"; + } + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Exception propagation +Fiber: suspending +Got: suspend +Fiber: throwing +Caught in coroutine: fiber exception +Test completed diff --git a/tests/fiber/011-fiber_exception_nested_spawn.phpt b/tests/fiber/011-fiber_exception_nested_spawn.phpt new file mode 100644 index 0000000..d8ce4d0 --- /dev/null +++ b/tests/fiber/011-fiber_exception_nested_spawn.phpt @@ -0,0 +1,45 @@ +--TEST-- +Exception in nested spawn inside Fiber +--FILE-- +getMessage() . "\n"; + } + + return "fiber recovered"; + }); + + $result = $fiber->start(); + echo "Result: " . $result . "\n"; + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Exception in nested spawn +Fiber: spawning +Inner: throwing +Caught in fiber: inner exception +Result: fiber recovered +Test completed diff --git a/tests/fiber/012-fiber_status_methods.phpt b/tests/fiber/012-fiber_status_methods.phpt new file mode 100644 index 0000000..6084d86 --- /dev/null +++ b/tests/fiber/012-fiber_status_methods.phpt @@ -0,0 +1,64 @@ +--TEST-- +Fiber status methods +--FILE-- +isStarted() ? "Y" : "N") . "\n"; + echo "isSuspended: " . ($fiber->isSuspended() ? "Y" : "N") . "\n"; + echo "isRunning: " . ($fiber->isRunning() ? "Y" : "N") . "\n"; + echo "isTerminated: " . ($fiber->isTerminated() ? "Y" : "N") . "\n"; + + $fiber->start(); + + echo "=== After suspend ===\n"; + echo "isStarted: " . ($fiber->isStarted() ? "Y" : "N") . "\n"; + echo "isSuspended: " . ($fiber->isSuspended() ? "Y" : "N") . "\n"; + echo "isRunning: " . ($fiber->isRunning() ? "Y" : "N") . "\n"; + echo "isTerminated: " . ($fiber->isTerminated() ? "Y" : "N") . "\n"; + + $fiber->resume(); + $fiber->resume(); + + echo "=== After terminated ===\n"; + echo "isStarted: " . ($fiber->isStarted() ? "Y" : "N") . "\n"; + echo "isSuspended: " . ($fiber->isSuspended() ? "Y" : "N") . "\n"; + echo "isRunning: " . ($fiber->isRunning() ? "Y" : "N") . "\n"; + echo "isTerminated: " . ($fiber->isTerminated() ? "Y" : "N") . "\n"; + + return "complete"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Fiber status methods +=== Before start === +isStarted: N +isSuspended: N +isRunning: N +isTerminated: N +=== After suspend === +isStarted: Y +isSuspended: Y +isRunning: N +isTerminated: N +=== After terminated === +isStarted: Y +isSuspended: N +isRunning: N +isTerminated: Y +Test completed diff --git a/tests/fiber/013-fiber_getReturn.phpt b/tests/fiber/013-fiber_getReturn.phpt new file mode 100644 index 0000000..52bbf19 --- /dev/null +++ b/tests/fiber/013-fiber_getReturn.phpt @@ -0,0 +1,32 @@ +--TEST-- +Fiber.getReturn() method +--FILE-- +start(); + $fiber->resume(); + + $result = $fiber->getReturn(); + echo "getReturn: " . $result . "\n"; + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Fiber getReturn +getReturn: final result +Test completed diff --git a/tests/fiber/014-fiber_gc_suspended.phpt b/tests/fiber/014-fiber_gc_suspended.phpt new file mode 100644 index 0000000..4dccb4a --- /dev/null +++ b/tests/fiber/014-fiber_gc_suspended.phpt @@ -0,0 +1,38 @@ +--TEST-- +Fiber garbage collection when suspended +--FILE-- +start(); + echo "Fiber suspended\n"; + + // Release reference + unset($fiber); + gc_collect_cycles(); + + echo "Fiber GC'd\n"; + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Fiber GC when suspended +Before suspend +Fiber suspended +Fiber GC'd +Test completed diff --git a/tests/fiber/015-fiber_null_values.phpt b/tests/fiber/015-fiber_null_values.phpt new file mode 100644 index 0000000..01638da --- /dev/null +++ b/tests/fiber/015-fiber_null_values.phpt @@ -0,0 +1,42 @@ +--TEST-- +Fiber with NULL values in suspend/resume +--FILE-- +start(); + echo "Suspend value is null: " . ($s1 === null ? "Y" : "N") . "\n"; + + $s2 = $fiber->resume(); + echo "Suspend value is null: " . ($s2 === null ? "Y" : "N") . "\n"; + + $r = $fiber->resume(null); + echo "Return value is null: " . ($r === null ? "Y" : "N") . "\n"; + + return "done"; +}); + +await($coroutine); +echo "Test completed\n"; +?> +--EXPECT-- +Test: NULL values in suspend/resume +Suspend value is null: Y +Resume value is null: Y +Suspend value is null: Y +Return value is null: Y +Test completed diff --git a/tests/fiber/016-fiber_nested_in_separate_coroutines.phpt b/tests/fiber/016-fiber_nested_in_separate_coroutines.phpt new file mode 100644 index 0000000..a1e4c40 --- /dev/null +++ b/tests/fiber/016-fiber_nested_in_separate_coroutines.phpt @@ -0,0 +1,63 @@ +--TEST-- +Nested fibers in separate coroutines (symmetric switching) +--FILE-- +start(); + echo "C1-O-middle\n"; + $inner->resume(); + echo "C1-O-end\n"; + }); + + $outer->start(); +}); + +$c2 = spawn(function() { + $outer = new Fiber(function() { + echo "C2-O-start\n"; + + $inner = new Fiber(function() { + echo "C2-I-start\n"; + Fiber::suspend(); + echo "C2-I-resume\n"; + }); + + $inner->start(); + echo "C2-O-middle\n"; + $inner->resume(); + echo "C2-O-end\n"; + }); + + $outer->start(); +}); + +await($c1); +await($c2); + +echo "OK\n"; +?> +--EXPECT-- +C1-O-start +C2-O-start +C1-I-start +C2-I-start +C1-O-middle +C2-O-middle +C1-I-resume +C2-I-resume +C1-O-end +C2-O-end +OK diff --git a/tests/fiber/017-fiber_suspend_resume_multiple.phpt b/tests/fiber/017-fiber_suspend_resume_multiple.phpt new file mode 100644 index 0000000..923e46a --- /dev/null +++ b/tests/fiber/017-fiber_suspend_resume_multiple.phpt @@ -0,0 +1,49 @@ +--TEST-- +Multiple fiber suspend/resume in different coroutines +--FILE-- +start(); + $f->resume(); + $f->resume(); +}); + +$c2 = spawn(function() { + $f = new Fiber(function() { + echo "C2-1\n"; + Fiber::suspend(); + echo "C2-2\n"; + Fiber::suspend(); + echo "C2-3\n"; + }); + + $f->start(); + $f->resume(); + $f->resume(); +}); + +await($c1); +await($c2); + +echo "OK\n"; +?> +--EXPECT-- +C1-1 +C2-1 +C1-2 +C2-2 +C1-3 +C2-3 +OK diff --git a/tests/fiber/018-fiber_coroutine_gc_cycles.phpt b/tests/fiber/018-fiber_coroutine_gc_cycles.phpt new file mode 100644 index 0000000..2ad33af --- /dev/null +++ b/tests/fiber/018-fiber_coroutine_gc_cycles.phpt @@ -0,0 +1,56 @@ +--TEST-- +Fiber and Coroutine GC with circular references +--FILE-- +data = str_repeat("x", 1000); + } +} + +$c = spawn(function() { + $node1 = new Node(); + $node2 = new Node(); + + // Create circular reference + $node1->ref = $node2; + $node2->ref = $node1; + + // Store in fiber + $node1->fiber = new Fiber(function() use ($node1, $node2) { + echo "F-start\n"; + Fiber::suspend($node2); + echo "F-resume\n"; + return $node1; + }); + + $result = $node1->fiber->start(); + echo "Got: " . ($result === $node2 ? "node2" : "other") . "\n"; + + $result = $node1->fiber->resume(); + echo "Got: " . ($result === $node1 ? "node1" : "other") . "\n"; + + // Break references and trigger GC + $node1 = null; + $node2 = null; +}); + +await($c); +gc_collect_cycles(); + +echo "OK\n"; +?> +--EXPECT-- +F-start +Got: node2 +F-resume +Got: node1 +OK diff --git a/tests/fiber/019-fiber_getCoroutine.phpt b/tests/fiber/019-fiber_getCoroutine.phpt new file mode 100644 index 0000000..74f6f1e --- /dev/null +++ b/tests/fiber/019-fiber_getCoroutine.phpt @@ -0,0 +1,35 @@ +--TEST-- +Fiber::getCoroutine() method +--FILE-- +start(); + + $coro = $f->getCoroutine(); + echo "Has coroutine: " . ($coro !== null ? "yes" : "no") . "\n"; + echo "Has ID: " . ($coro->getId() > 0 ? "yes" : "no") . "\n"; + echo "Is started: " . ($coro->isStarted() ? "yes" : "no") . "\n"; + echo "Is suspended: " . ($coro->isSuspended() ? "yes" : "no") . "\n"; + + $f->resume(); +}); + +await($c); + +echo "OK\n"; +?> +--EXPECT-- +Has coroutine: yes +Has ID: yes +Is started: yes +Is suspended: yes +OK diff --git a/tests/fiber/020-fiber_gc_during_start.phpt b/tests/fiber/020-fiber_gc_during_start.phpt new file mode 100644 index 0000000..1395344 --- /dev/null +++ b/tests/fiber/020-fiber_gc_during_start.phpt @@ -0,0 +1,54 @@ +--TEST-- +Fiber GC during another fiber start (Revolt scenario) +--FILE-- +fiber = new Fiber(function() { + echo "Temp fiber\n"; + }); + } + + public function __destruct() { + echo "FiberHolder destroyed\n"; + } +} + +$c = spawn(function() { + // Create a temporary fiber holder (like Revolt's temporary driver) + $temp = new FiberHolder(); + + // Create the main fiber (like Revolt's actual driver) + $mainFiber = new Fiber(function() { + echo "Main fiber started\n"; + }); + + // Unset temp to make it eligible for GC + unset($temp); + + // Starting main fiber might trigger GC, which destroys temp + // This simulates what happens in Revolt when EventLoop::setDriver creates + // a temporary driver, then the real driver, and GC happens on fiber->start() + $mainFiber->start(); + + return "done"; +}); + +await($c); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Fiber GC during start +FiberHolder created +Main fiber started +Test completed +FiberHolder destroyed \ No newline at end of file diff --git a/tests/fiber/021-multiple_fibers_gc_on_start.phpt b/tests/fiber/021-multiple_fibers_gc_on_start.phpt new file mode 100644 index 0000000..9cf399d --- /dev/null +++ b/tests/fiber/021-multiple_fibers_gc_on_start.phpt @@ -0,0 +1,67 @@ +--TEST-- +Multiple fibers GC during start (Revolt driver scenario) +--FILE-- +loopFiber = new Fiber(function() { + echo "Loop fiber\n"; + }); + + $this->callbackFiber = new Fiber(function() { + echo "Callback fiber\n"; + }); + } + + public function __destruct() { + echo "Driver destroyed\n"; + } +} + +$c = spawn(function() { + // Create temporary driver (like Revolt's GC protection driver) + $tempDriver = new Driver(); + + // Create actual driver + $actualDriver = new Driver(); + + // Replace temp with actual (simulates setDriver) + $tempDriver = null; + + // Now create and start a fiber (simulates run()) + // This should trigger GC which destroys the temp driver + $runFiber = new Fiber(function() { + echo "Run fiber started\n"; + }); + + echo "About to start run fiber\n"; + $runFiber->start(); + echo "Run fiber completed\n"; + + return "done"; +}); + +await($c); +echo "Test completed\n"; +?> +--EXPECT-- +Test: Multiple fibers GC on start +Driver created +Driver created +About to start run fiber +Run fiber started +Run fiber completed +Test completed +Driver destroyed +Driver destroyed \ No newline at end of file diff --git a/tests/fiber/022-fiber_suspended_after_manager_terminates.phpt b/tests/fiber/022-fiber_suspended_after_manager_terminates.phpt new file mode 100644 index 0000000..84bee56 --- /dev/null +++ b/tests/fiber/022-fiber_suspended_after_manager_terminates.phpt @@ -0,0 +1,51 @@ +--TEST-- +Fiber remains suspended after manager fiber terminates (event loop scenario) +--FILE-- +start(); + + echo "Manager: resuming worker once\n"; + $workerFiber->resume(); + + echo "Manager: no more work, terminating\n"; + // Manager terminates here, leaving worker suspended + return "done"; +}); + +echo "Starting manager\n"; +$result = $managerFiber->start(); + +echo "Manager terminated with: $result\n"; +echo "Worker is: " . ($workerFiber->isSuspended() ? "suspended" : "other") . "\n"; + +echo "Test completed\n"; +?> +--EXPECT-- +Test: Suspended worker fiber after manager terminates +Starting manager +Manager: starting worker +Worker: started +Worker: processing +Manager: resuming worker once +Worker: resumed +Worker: processing +Manager: no more work, terminating +Manager terminated with: done +Worker is: suspended +Test completed diff --git a/tests/fiber/023-fiber_cancel_via_coroutine.phpt b/tests/fiber/023-fiber_cancel_via_coroutine.phpt new file mode 100644 index 0000000..6cbddc1 --- /dev/null +++ b/tests/fiber/023-fiber_cancel_via_coroutine.phpt @@ -0,0 +1,45 @@ +--TEST-- +Cancel fiber's coroutine via getCoroutine()->cancel() +--FILE-- +start(); + echo "Fiber suspended\n"; + + // Get fiber's coroutine and cancel it + $coro = $fiber->getCoroutine(); + $coro->cancel(new CancellationError("cancelled")); + echo "Coroutine cancelled\n"; + + // Try to resume fiber + try { + $fiber->resume(); + echo "Fiber completed\n"; + } catch (CancellationError $e) { + echo "CancellationError: " . $e->getMessage() . "\n"; + } catch (FiberError $e) { + echo "FiberError: " . $e->getMessage() . "\n"; + } +}); + +await($c); +echo "OK\n"; +?> +--EXPECTF-- +Fiber started +Fiber suspended +Coroutine cancelled +%a +OK diff --git a/tests/fiber/024-fiber_cancel_during_suspend.phpt b/tests/fiber/024-fiber_cancel_during_suspend.phpt new file mode 100644 index 0000000..56d49f1 --- /dev/null +++ b/tests/fiber/024-fiber_cancel_during_suspend.phpt @@ -0,0 +1,44 @@ +--TEST-- +Cancel fiber's coroutine while fiber is suspended +--FILE-- +start(); + + // Fiber is suspended, cancel its coroutine + $coro = $fiber->getCoroutine(); + echo "Cancelling coroutine\n"; + $coro->cancel(new CancellationError("test")); + + // Give scheduler a chance + suspend(); + + // Try resume + try { + $fiber->resume(); + } catch (Throwable $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } +}); + +await($c); +echo "OK\n"; +?> +--EXPECTF-- +Fiber: before suspend +Cancelling coroutine +Caught: Cannot resume a fiber that is not suspended +OK diff --git a/tests/fiber/025-fiber_double_cancel.phpt b/tests/fiber/025-fiber_double_cancel.phpt new file mode 100644 index 0000000..bfe03f0 --- /dev/null +++ b/tests/fiber/025-fiber_double_cancel.phpt @@ -0,0 +1,47 @@ +--TEST-- +Double cancel - parent coroutine and fiber's coroutine +--FILE-- +start(); + + // Cancel fiber's coroutine + $fiberCoro = $fiber->getCoroutine(); + $fiberCoro->cancel(new CancellationError("fiber cancel")); + echo "Fiber coroutine cancelled\n"; + + // Try resume + try { + $fiber->resume(); + } catch (Throwable $e) { + echo "Fiber caught: " . $e->getMessage() . "\n"; + } + + return "parent done"; +}); + +// Also cancel parent +$parent->cancel(new CancellationError("parent cancel")); +echo "Parent cancelled\n"; + +try { + await($parent); +} catch (CancellationError $e) { + echo "Parent caught: " . $e->getMessage() . "\n"; +} + +echo "OK\n"; +?> +--EXPECTF-- +%a +OK diff --git a/tests/fiber/026-fiber_getCoroutine_after_termination.phpt b/tests/fiber/026-fiber_getCoroutine_after_termination.phpt new file mode 100644 index 0000000..a640526 --- /dev/null +++ b/tests/fiber/026-fiber_getCoroutine_after_termination.phpt @@ -0,0 +1,37 @@ +--TEST-- +Get fiber's coroutine after fiber termination +--FILE-- +start(); + echo "Fiber completed: {$result}\n"; + + // Get coroutine after fiber is terminated + $coro = $fiber->getCoroutine(); + + if ($coro !== null) { + echo "Has coroutine: yes\n"; + echo "Is finished: " . ($coro->isFinished() ? "yes" : "no") . "\n"; + } else { + echo "Has coroutine: no\n"; + } + + return "ok"; +}); + +await($c); +echo "OK\n"; +?> +--EXPECTF-- +Fiber completed: done +Has coroutine: yes +Is finished: yes +OK diff --git a/tests/fiber/027-fiber_cancel_in_nested_spawn.phpt b/tests/fiber/027-fiber_cancel_in_nested_spawn.phpt new file mode 100644 index 0000000..78a2259 --- /dev/null +++ b/tests/fiber/027-fiber_cancel_in_nested_spawn.phpt @@ -0,0 +1,49 @@ +--TEST-- +Cancel fiber's coroutine from nested spawn +--FILE-- +start(); + $fiberCoro = $fiber->getCoroutine(); + + // Nested coroutine cancels fiber's coroutine + $inner = spawn(function() use ($fiberCoro) { + echo "Inner: cancelling fiber coroutine\n"; + $fiberCoro->cancel(new CancellationError("nested cancel")); + }); + + await($inner); + suspend(); + + // Try to use fiber + try { + $fiber->resume(); + } catch (Throwable $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + return "outer done"; +}); + +$result = await($outer); +echo "Result: {$result}\n"; +echo "OK\n"; +?> +--EXPECTF-- +Fiber running +Inner: cancelling fiber coroutine +%a +OK diff --git a/tests/fiber/028-fiber_exception_vs_cancel.phpt b/tests/fiber/028-fiber_exception_vs_cancel.phpt new file mode 100644 index 0000000..f14cb9e --- /dev/null +++ b/tests/fiber/028-fiber_exception_vs_cancel.phpt @@ -0,0 +1,38 @@ +--TEST-- +Fiber throws exception while coroutine is being cancelled +--FILE-- +start(); + $coro = $fiber->getCoroutine(); + + // Cancel coroutine + $coro->cancel(new CancellationError("cancel")); + + // Resume - what happens? Exception or CancellationError? + try { + $fiber->resume(); + echo "No exception\n"; + } catch (CancellationError $e) { + echo "CancellationError: " . $e->getMessage() . "\n"; + } catch (Exception $e) { + echo "Exception: " . $e->getMessage() . "\n"; + } +}); + +await($c); +echo "OK\n"; +?> +--EXPECTF-- +%a +OK diff --git a/tests/gc/013-gc-fiber-destructors.phpt b/tests/gc/013-gc-fiber-destructors.phpt index e2c30ed..4644b46 100644 --- a/tests/gc/013-gc-fiber-destructors.phpt +++ b/tests/gc/013-gc-fiber-destructors.phpt @@ -16,11 +16,6 @@ class Cycle { public function __destruct() { $id = self::$counter++; printf("%d: Start destruct\n", $id); - if ($id === 0) { - global $f2; - $f2 = Fiber::getCurrent(); - Fiber::suspend(new stdClass); - } printf("%d: End destruct\n", $id); } } @@ -37,16 +32,14 @@ new Cycle(); new Cycle(); gc_collect_cycles(); -$f2->resume(); - ?> --EXPECT-- 0: Start destruct +0: End destruct 1: Start destruct 1: End destruct 2: Start destruct 2: End destruct 3: Start destruct 3: End destruct -0: End destruct Shutdown diff --git a/tests/gc/014-gc-after-excaption-in-main.phpt b/tests/gc/014-gc-after-excaption-in-main.phpt new file mode 100644 index 0000000..613630a --- /dev/null +++ b/tests/gc/014-gc-after-excaption-in-main.phpt @@ -0,0 +1,52 @@ +--TEST-- +Correct GC behavior when the main coroutine is destroyed due to an exception. +--FILE-- +self = $this; + } + public function __destruct() { + $id = self::$counter++; + printf("%d: Start destruct\n", $id); + printf("%d: End destruct\n", $id); + } +} + +$f = new Fiber(function () { + new Cycle(); + new Cycle(); + gc_collect_cycles(); +}); + +$f->start(); + +new Cycle(); +new Cycle(); +gc_collect_cycles(); + +throw new Exception("Trigger GC"); + +?> +--EXPECTF-- +0: Start destruct +0: End destruct +1: Start destruct +1: End destruct +2: Start destruct +2: End destruct +3: Start destruct +3: End destruct + +Fatal error: Uncaught Exception: Trigger GC in %s:%d +Stack trace: +#0 {main} + thrown in %s on line %d +Shutdown \ No newline at end of file diff --git a/tests/stream/032-stream_select_null_timeout.phpt b/tests/stream/032-stream_select_null_timeout.phpt new file mode 100644 index 0000000..a05cfdb --- /dev/null +++ b/tests/stream/032-stream_select_null_timeout.phpt @@ -0,0 +1,93 @@ +--TEST-- +stream_select with null timeout (infinite wait with coroutine yield) +--FILE-- + 0) { + echo "Selector: data available\n"; + $data = fread($sock2, 1024); + echo "Selector: read '$data'\n"; + } else { + echo "Selector: ERROR - should have received data!\n"; + } + + fclose($sock2); + return "selector completed"; +}); + +// Writer coroutine - writes after a small delay +$writer = spawn(function() use ($sock1) { + // Small delay to ensure selector starts first + \Async\delay(50); + + echo "Writer: writing data\n"; + fwrite($sock1, "test data from writer"); + fflush($sock1); + echo "Writer: data written\n"; + + fclose($sock1); + return "writer completed"; +}); + +// Worker to demonstrate parallel execution +$worker = spawn(function() { + echo "Worker: executing during stream_select\n"; + \Async\delay(10); + echo "Worker: finished\n"; + return "worker completed"; +}); + +list($results, $errors) = awaitAll([$selector, $writer, $worker]); + +echo "Results:\n"; +foreach ($results as $i => $result) { + echo " Coroutine $i: $result\n"; +} + +echo "Test completed successfully\n"; + +?> +--EXPECTF-- +Testing stream_select with null timeout +Selector: calling stream_select with null timeout +Worker: executing during stream_select +Worker: finished +Writer: writing data +Writer: data written +Selector: stream_select returned after %sms +Selector: data available +Selector: read 'test data from writer' +Results: + Coroutine 0: selector completed + Coroutine 1: writer completed + Coroutine 2: worker completed +Test completed successfully