From 7a3f11305b7f98f60dc5b467f29378d3ccbe541d Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:23:59 +0300 Subject: [PATCH 1/4] #23: + edge cases for fiber from spawn and spawn from fiber --- scheduler.c | 5 + .../007-fiber_first_then_spawn.phpt | 50 ++++++++ .../008-spawn_first_then_fiber.phpt | 61 +++++++++ .../009-fiber_spawn_destructor.phpt | 116 ++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 tests/edge_cases/007-fiber_first_then_spawn.phpt create mode 100644 tests/edge_cases/008-spawn_first_then_fiber.phpt create mode 100644 tests/edge_cases/009-fiber_spawn_destructor.phpt diff --git a/scheduler.c b/scheduler.c index 46db144..fbb227a 100644 --- a/scheduler.c +++ b/scheduler.c @@ -523,6 +523,11 @@ void async_scheduler_launch(void) return; } + if (EG(active_fiber)) { + async_throw_error("The True Async Scheduler cannot be started from within a Fiber"); + return; + } + if (false == zend_async_reactor_is_enabled()) { async_throw_error("The scheduler cannot be started without the Reactor"); return; diff --git a/tests/edge_cases/007-fiber_first_then_spawn.phpt b/tests/edge_cases/007-fiber_first_then_spawn.phpt new file mode 100644 index 0000000..39b5163 --- /dev/null +++ b/tests/edge_cases/007-fiber_first_then_spawn.phpt @@ -0,0 +1,50 @@ +--TEST-- +Fiber created first, then spawn operation - should detect incompatible context +--FILE-- +start(); + echo "Fiber suspended with: " . $result . "\n"; + + echo "Resuming Fiber\n"; + $result = $fiber->resume("resume value"); + echo "Fiber returned: " . $result . "\n"; + +} catch (Async\AsyncException $e) { + echo "Async exception caught: " . $e->getMessage() . "\n"; +} catch (Exception $e) { + echo "Exception caught: " . $e->getMessage() . "\n"; +} + +echo "Test completed\n"; +?> +--EXPECTF-- +Test: Fiber first, then spawn +Starting Fiber +Inside Fiber +Async exception caught: The True Async Scheduler cannot be started from within a Fiber +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 new file mode 100644 index 0000000..fb6f8f8 --- /dev/null +++ b/tests/edge_cases/008-spawn_first_then_fiber.phpt @@ -0,0 +1,61 @@ +--TEST-- +Spawn coroutine first, then create Fiber - should detect context conflicts +--FILE-- +start(); + echo "Fiber suspended with: " . $fiberResult . "\n"; + + echo "Resuming Fiber\n"; + $fiberResult = $fiber->resume("resume data"); + echo "Fiber completed with: " . $fiberResult . "\n"; + + echo "Getting coroutine result\n"; + $coroutineResult = await($coroutine); + echo "Coroutine completed with: " . $coroutineResult . "\n"; + +} catch (Error $e) { + echo "Error caught: " . $e->getMessage() . "\n"; +} catch (Exception $e) { + echo "Exception caught: " . $e->getMessage() . "\n"; +} + +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 +Coroutine started +Coroutine resumed \ 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 new file mode 100644 index 0000000..b90e845 --- /dev/null +++ b/tests/edge_cases/009-fiber_spawn_destructor.phpt @@ -0,0 +1,116 @@ +--TEST-- +Fiber and spawn operations in destructors - memory management conflicts +--FILE-- +name = $name; + echo "Created: {$this->name}\n"; + } + + public function __destruct() { + echo "Destructing: {$this->name}\n"; + + try { + if ($this->name === 'FiberInDestructor') { + // Create Fiber in destructor + $fiber = new Fiber(function() { + echo "Fiber running in destructor\n"; + Fiber::suspend("destructor fiber"); + echo "Fiber resumed in destructor\n"; + return "destructor done"; + }); + + echo "Starting fiber in destructor\n"; + $result = $fiber->start(); + echo "Fiber suspended with: " . $result . "\n"; + + $result = $fiber->resume("resume in destructor"); + echo "Fiber completed with: " . $result . "\n"; + + } elseif ($this->name === 'SpawnInDestructor') { + // Spawn coroutine in destructor + echo "Spawning coroutine in destructor\n"; + $coroutine = spawn(function() { + echo "Coroutine running in destructor\n"; + suspend(); + echo "Coroutine resumed in destructor\n"; + return "destructor coroutine done"; + }); + + echo "Waiting for coroutine in destructor\n"; + $result = await($coroutine); + echo "Coroutine completed with: " . $result . "\n"; + } + } catch (Error $e) { + echo "Error in destructor: " . $e->getMessage() . "\n"; + } catch (Exception $e) { + echo "Exception in destructor: " . $e->getMessage() . "\n"; + } + + echo "Destructor finished: {$this->name}\n"; + } +} + +try { + echo "Creating objects that will spawn/fiber in destructors\n"; + + $obj1 = new FiberSpawner('FiberInDestructor'); + $obj2 = new FiberSpawner('SpawnInDestructor'); + + echo "Starting some async operations\n"; + $mainCoroutine = spawn(function() { + echo "Main coroutine running\n"; + suspend(); + echo "Main coroutine resumed\n"; + return "main done"; + }); + + // Force destruction by unsetting + echo "Unsetting objects to trigger destructors\n"; + unset($obj1); + unset($obj2); + + echo "Completing main coroutine\n"; + $result = await($mainCoroutine); + echo "Main coroutine result: " . $result . "\n"; + +} catch (Error $e) { + echo "Error caught: " . $e->getMessage() . "\n"; +} catch (Exception $e) { + echo "Exception caught: " . $e->getMessage() . "\n"; +} + +echo "Test completed\n"; +?> +--EXPECTF-- +Test: Fiber and spawn in destructors +Creating objects that will spawn/fiber in destructors +Created: FiberInDestructor +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 +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 +Completing main coroutine +Main coroutine result: main done +Test completed \ No newline at end of file From 127026305491910b2d3e83bd89fc9d73bc20af28 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Fri, 11 Jul 2025 10:36:11 +0300 Subject: [PATCH 2/4] #23: * fix Fiber created first, then spawn operation - should detect incompatible context --- tests/edge_cases/007-fiber_first_then_spawn.phpt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/edge_cases/007-fiber_first_then_spawn.phpt b/tests/edge_cases/007-fiber_first_then_spawn.phpt index 39b5163..9270cfb 100644 --- a/tests/edge_cases/007-fiber_first_then_spawn.phpt +++ b/tests/edge_cases/007-fiber_first_then_spawn.phpt @@ -46,5 +46,5 @@ echo "Test completed\n"; Test: Fiber first, then spawn Starting Fiber Inside Fiber -Async exception caught: The True Async Scheduler cannot be started from within a Fiber +Async exception caught: Cannot spawn a coroutine when async is disabled Test completed \ No newline at end of file From 9d342922522bac8fac05d4d3fccb615d4d748320 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:26:32 +0300 Subject: [PATCH 3/4] #23: * Adjustment of Scope: By default, the Scope is created in safely dispose mode. --- scheduler.c | 4 +- scope.c | 42 ++++++++++-- ...010-deadlock-after-cancel-with-zombie.phpt | 65 +++++++++++++++++++ ...24-scope_awaitAfterCancellation_basic.phpt | 2 + ..._awaitAfterCancellation_error_handler.phpt | 3 +- ...6-scope_cancel_with_active_coroutines.phpt | 2 +- .../029-scope_complex_tree_cancellation.phpt | 2 +- ...3-scope_cancellation_finally_handlers.phpt | 2 +- 8 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 tests/edge_cases/010-deadlock-after-cancel-with-zombie.phpt diff --git a/scheduler.c b/scheduler.c index fbb227a..da40686 100644 --- a/scheduler.c +++ b/scheduler.c @@ -891,7 +891,7 @@ void async_scheduler_coroutine_suspend(zend_fiber_transfer *transfer) if (UNEXPECTED( false == has_handles && false == is_next_coroutine - && ZEND_ASYNC_ACTIVE_COROUTINE_COUNT > 0 + && zend_hash_num_elements(&ASYNC_G(coroutines)) > 0 && circular_buffer_is_empty(&ASYNC_G(microtasks)) && resolve_deadlocks() )) { @@ -955,7 +955,7 @@ void async_scheduler_main_loop(void) if (UNEXPECTED( false == has_handles && false == was_executed - && ZEND_ASYNC_ACTIVE_COROUTINE_COUNT > 0 + && zend_hash_num_elements(&ASYNC_G(coroutines)) > 0 && circular_buffer_is_empty(&ASYNC_G(coroutine_queue)) && circular_buffer_is_empty(&ASYNC_G(microtasks)) && resolve_deadlocks() diff --git a/scope.c b/scope.c index 6458363..f546dde 100644 --- a/scope.c +++ b/scope.c @@ -63,12 +63,10 @@ async_scope_remove_coroutine(async_scope_t *scope, async_coroutine_t *coroutine) for (uint32_t i = 0; i < vector->length; ++i) { if (vector->data[i] == coroutine) { // Decrement active coroutines count if coroutine was active - if (false == ZEND_COROUTINE_IS_ZOMBIE(&coroutine->coroutine)) { - if (scope->active_coroutines_count > 0) { - scope->active_coroutines_count--; - } else if (scope->zombie_coroutines_count > 0) { - scope->zombie_coroutines_count--; - } + if (false == ZEND_COROUTINE_IS_ZOMBIE(&coroutine->coroutine) && scope->active_coroutines_count > 0) { + scope->active_coroutines_count--; + } else if (scope->zombie_coroutines_count > 0) { + scope->zombie_coroutines_count--; } vector->data[i] = vector->data[--vector->length]; @@ -1087,6 +1085,12 @@ static zend_string* scope_info(zend_async_event_t *event) static void scope_dispose(zend_async_event_t *scope_event) { + async_scope_t *scope = (async_scope_t *) scope_event; + + if (ZEND_ASYNC_SCOPE_IS_DISPOSING(&scope->scope)) { + return; + } + if (ZEND_ASYNC_EVENT_REF(scope_event) > 1) { ZEND_ASYNC_EVENT_DEL_REF(scope_event); return; @@ -1096,16 +1100,31 @@ static void scope_dispose(zend_async_event_t *scope_event) ZEND_ASYNC_EVENT_DEL_REF(scope_event); } - async_scope_t *scope = (async_scope_t *) scope_event; + ZEND_ASYNC_SCOPE_SET_DISPOSING(&scope->scope); ZEND_ASSERT(scope->coroutines.length == 0 && scope->scope.scopes.length == 0 && "Scope should be empty before disposal"); + zend_object *critical_exception = NULL; + + // + // Notifying subscribers one last time that the scope has been definitively completed. + // + ZEND_ASYNC_CALLBACKS_NOTIFY(scope_event, NULL, NULL); + zend_async_callbacks_free(&scope->scope.event); + if (UNEXPECTED(EG(exception))) { + critical_exception = zend_exception_merge(critical_exception, true, true); + } + if (scope->finally_handlers != NULL && zend_hash_num_elements(scope->finally_handlers) > 0 && async_scope_call_finally_handlers(scope)) { // If finally handlers were called, we don't dispose the scope yet ZEND_ASYNC_EVENT_ADD_REF(&scope->scope.event); + if (critical_exception) { + async_spawn_and_throw(critical_exception, &scope->scope, 0); + } + ZEND_ASYNC_SCOPE_CLR_DISPOSING(&scope->scope); return; } @@ -1153,6 +1172,10 @@ static void scope_dispose(zend_async_event_t *scope_event) async_scope_free_coroutines(scope); zend_async_scope_free_children(&scope->scope); efree(scope); + + if (critical_exception != NULL) { + async_rethrow_exception(critical_exception); + } } zend_async_scope_t * async_new_scope(zend_async_scope_t * parent_scope, const bool with_zend_object) @@ -1181,6 +1204,11 @@ zend_async_scope_t * async_new_scope(zend_async_scope_t * parent_scope, const bo scope->scope.parent_scope = parent_scope; zend_async_event_t *event = &scope->scope.event; + // Inherit safely disposal flag from parent scope or set it to true if parent scope is NULL + if (parent_scope == NULL || ZEND_ASYNC_SCOPE_IS_DISPOSE_SAFELY(parent_scope)) { + ZEND_ASYNC_SCOPE_SET_DISPOSE_SAFELY(&scope->scope); + } + event->ref_count = 1; // Initialize reference count scope->scope.before_coroutine_enqueue = scope_before_coroutine_enqueue; diff --git a/tests/edge_cases/010-deadlock-after-cancel-with-zombie.phpt b/tests/edge_cases/010-deadlock-after-cancel-with-zombie.phpt new file mode 100644 index 0000000..0932908 --- /dev/null +++ b/tests/edge_cases/010-deadlock-after-cancel-with-zombie.phpt @@ -0,0 +1,65 @@ +--TEST-- +Deadlock - Deadlock is an operation after coroutines are cancelled, when they are already zombies. +--FILE-- +spawn(function() use (&$coroutine2) { + echo "coroutine1 running\n"; + suspend(); + + try { + await($coroutine2); + } catch (Throwable $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; + } + + suspend(); + + echo "coroutine1 finished\n"; +}); + +$coroutine2 = $scope->spawn(function() use ($coroutine1) { + echo "coroutine2 running\n"; + suspend(); + try { + await($coroutine1); + } catch (Throwable $e) { + echo "Caught exception: " . $e->getMessage() . "\n"; + } + + suspend(); + + echo "coroutine2 finished\n"; +}); + +suspend(); // Suspend the main coroutine to allow the others to run +$scope->dispose(); // This will cancel the coroutines, making them zombies + +echo "end\n"; +?> +--EXPECTF-- +start +coroutine1 running +coroutine2 running +end + +Warning: no active coroutines, deadlock detected. Coroutines in waiting: 2 in %s on line %d + +Warning: the coroutine was suspended in file: %s, line: %d will be canceled in %s on line %d + +Warning: the coroutine was suspended in file: %s, line: %d will be canceled in %s on line %d +Caught exception: Deadlock detected +coroutine1 finished +coroutine2 finished \ No newline at end of file diff --git a/tests/scope/024-scope_awaitAfterCancellation_basic.phpt b/tests/scope/024-scope_awaitAfterCancellation_basic.phpt index a845a78..3b125b8 100644 --- a/tests/scope/024-scope_awaitAfterCancellation_basic.phpt +++ b/tests/scope/024-scope_awaitAfterCancellation_basic.phpt @@ -60,6 +60,8 @@ coroutine1 started coroutine2 started scope cancelled external waiting after cancellation +coroutine1 finished +coroutine2 finished awaitAfterCancellation completed scope finished: true scope closed: true diff --git a/tests/scope/025-scope_awaitAfterCancellation_error_handler.phpt b/tests/scope/025-scope_awaitAfterCancellation_error_handler.phpt index f9924ea..2febaed 100644 --- a/tests/scope/025-scope_awaitAfterCancellation_error_handler.phpt +++ b/tests/scope/025-scope_awaitAfterCancellation_error_handler.phpt @@ -71,9 +71,8 @@ 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 +normal coroutine finished 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 index 5539a7c..3297c2f 100644 --- a/tests/scope/026-scope_cancel_with_active_coroutines.phpt +++ b/tests/scope/026-scope_cancel_with_active_coroutines.phpt @@ -10,7 +10,7 @@ use Async\Scope; echo "start\n"; // Test comprehensive cancellation behavior -$scope = Scope::inherit(); +$scope = Scope::inherit()->asNotSafely(); $coroutine1 = $scope->spawn(function() { echo "coroutine1 started\n"; diff --git a/tests/scope/029-scope_complex_tree_cancellation.phpt b/tests/scope/029-scope_complex_tree_cancellation.phpt index 74ae830..62d82a4 100644 --- a/tests/scope/029-scope_complex_tree_cancellation.phpt +++ b/tests/scope/029-scope_complex_tree_cancellation.phpt @@ -10,7 +10,7 @@ use function Async\await; echo "start\n"; // Create complex scope tree: parent -> child -> grandchild -> great-grandchild -$parent_scope = new \Async\Scope(); +$parent_scope = new \Async\Scope()->asNotSafely(); $child_scope = \Async\Scope::inherit($parent_scope); $grandchild_scope = \Async\Scope::inherit($child_scope); $great_grandchild_scope = \Async\Scope::inherit($grandchild_scope); diff --git a/tests/scope/033-scope_cancellation_finally_handlers.phpt b/tests/scope/033-scope_cancellation_finally_handlers.phpt index a423ed2..59b49c0 100644 --- a/tests/scope/033-scope_cancellation_finally_handlers.phpt +++ b/tests/scope/033-scope_cancellation_finally_handlers.phpt @@ -9,7 +9,7 @@ use function Async\await; echo "start\n"; -$scope = new \Async\Scope(); +$scope = new \Async\Scope()->asNotSafely(); // Spawn coroutine with finally handlers $coroutine_with_finally = $scope->spawn(function() { From 8ca3c01d301cfcacf2032bf9b1a2db640bb980ae Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Fri, 11 Jul 2025 15:44:33 +0300 Subject: [PATCH 4/4] #23: + Note that at this point we are finally awaiting the completion of all cancelled Futures. --- async_API.c | 1 + 1 file changed, 1 insertion(+) diff --git a/async_API.c b/async_API.c index 1053edf..4d7bf7f 100644 --- a/async_API.c +++ b/async_API.c @@ -1056,6 +1056,7 @@ void async_await_futures( // If the await on futures has completed and // the automatic cancellation mode for pending coroutines is active. + // !Note! that at this point we are finally awaiting the completion of all cancelled Futures. if (await_context->cancel_on_exit) { async_cancel_awaited_futures(await_context, futures); }