From 894d9779b561ea722fe9e917f8163dae66a0d00b Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Thu, 3 Jul 2025 13:43:20 +0300 Subject: [PATCH] #7: Refactoring of Zend GC logic for coroutines. Added GC context for traversing nodes. Edge cases fixed. + Additionally, for the Coroutine::cancel() method, the mandatory parameter has been removed and is now optional. --- coroutine.c | 13 +- coroutine.stub.php | 2 +- coroutine_arginfo.h | 6 +- tests/gc/001-gc_destructor_basic_suspend.phpt | 57 ++++++++ .../gc/002-gc_destructor_spawn_coroutine.phpt | 65 +++++++++ tests/gc/003-gc_destructor_resume_other.phpt | 77 ++++++++++ ...-gc_destructor_exception_with_suspend.phpt | 78 ++++++++++ tests/gc/005-gc_destructor_cycles_simple.phpt | 73 ++++++++++ tests/gc/006-gc_destructor_simple.phpt | 40 ++++++ .../007-gc_destructor_complex_async_ops.phpt | 99 +++++++++++++ ...008-gc_destructor_object_resurrection.phpt | 93 ++++++++++++ .../009-gc_destructor_shutdown_sequence.phpt | 96 +++++++++++++ .../010-gc_destructor_force_close_error.phpt | 108 ++++++++++++++ ...011-gc_destructor_cycles_with_suspend.phpt | 88 ++++++++++++ .../012-gc_destructor_multiple_gc_cycles.phpt | 98 +++++++++++++ tests/gc/README.md | 134 ++++++++++++++++++ 16 files changed, 1120 insertions(+), 7 deletions(-) create mode 100644 tests/gc/001-gc_destructor_basic_suspend.phpt create mode 100644 tests/gc/002-gc_destructor_spawn_coroutine.phpt create mode 100644 tests/gc/003-gc_destructor_resume_other.phpt create mode 100644 tests/gc/004-gc_destructor_exception_with_suspend.phpt create mode 100644 tests/gc/005-gc_destructor_cycles_simple.phpt create mode 100644 tests/gc/006-gc_destructor_simple.phpt create mode 100644 tests/gc/007-gc_destructor_complex_async_ops.phpt create mode 100644 tests/gc/008-gc_destructor_object_resurrection.phpt create mode 100644 tests/gc/009-gc_destructor_shutdown_sequence.phpt create mode 100644 tests/gc/010-gc_destructor_force_close_error.phpt create mode 100644 tests/gc/011-gc_destructor_cycles_with_suspend.phpt create mode 100644 tests/gc/012-gc_destructor_multiple_gc_cycles.phpt create mode 100644 tests/gc/README.md diff --git a/coroutine.c b/coroutine.c index 5dd96df..c91e7fc 100644 --- a/coroutine.c +++ b/coroutine.c @@ -256,10 +256,11 @@ METHOD(getAwaitingInfo) METHOD(cancel) { - zend_object *exception; + zend_object *exception = NULL; - ZEND_PARSE_PARAMETERS_START(1, 1) - Z_PARAM_OBJ_OF_CLASS(exception, zend_ce_cancellation_exception) + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL; + Z_PARAM_OBJ_OF_CLASS_OR_NULL(exception, zend_ce_cancellation_exception) ZEND_PARSE_PARAMETERS_END(); ZEND_ASYNC_CANCEL(&THIS_COROUTINE->coroutine, exception, false); @@ -582,6 +583,12 @@ void async_coroutine_finalize(zend_fiber_transfer *transfer, async_coroutine_t * zend_async_waker_destroy(&coroutine->coroutine); + if (coroutine->coroutine.extended_dispose != NULL) { + const zend_async_coroutine_dispose dispose = coroutine->coroutine.extended_dispose; + coroutine->coroutine.extended_dispose = NULL; + dispose(&coroutine->coroutine); + } + zend_exception_restore(); // If the exception was handled by any handler, we do not propagate it further. diff --git a/coroutine.stub.php b/coroutine.stub.php index 9a44363..b816de0 100644 --- a/coroutine.stub.php +++ b/coroutine.stub.php @@ -105,7 +105,7 @@ public function getAwaitingInfo(): array {} /** * Cancel the coroutine. */ - public function cancel(\CancellationException $cancellationException): void {} + public function cancel(?\CancellationException $cancellationException = null): void {} /** * Define a callback to be executed when the coroutine is finished. diff --git a/coroutine_arginfo.h b/coroutine_arginfo.h index b7e9dbe..2eecd06 100644 --- a/coroutine_arginfo.h +++ b/coroutine_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 550ad17a2db4553cda2febb49bb18249b62b8415 */ + * Stub hash: bee063ddc53348cc4a6ad23b9fc3415acc6d86f2 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_getId, 0, 0, IS_LONG, 0) ZEND_END_ARG_INFO() @@ -44,8 +44,8 @@ ZEND_END_ARG_INFO() #define arginfo_class_Async_Coroutine_getAwaitingInfo arginfo_class_Async_Coroutine_getTrace -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_cancel, 0, 1, IS_VOID, 0) - ZEND_ARG_OBJ_INFO(0, cancellationException, CancellationException, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_cancel, 0, 0, IS_VOID, 0) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, cancellationException, CancellationException, 1, "null") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_onFinally, 0, 1, IS_VOID, 0) diff --git a/tests/gc/001-gc_destructor_basic_suspend.phpt b/tests/gc/001-gc_destructor_basic_suspend.phpt new file mode 100644 index 0000000..69c9f10 --- /dev/null +++ b/tests/gc/001-gc_destructor_basic_suspend.phpt @@ -0,0 +1,57 @@ +--TEST-- +GC 001: Basic suspend in destructor +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + echo "Destructor start: {$this->value}\n"; + + // Suspend in destructor - this is the key test scenario + echo "Suspended in destructor: {$this->value}\n"; + suspend(); // No parameters - just yield control + + echo "Destructor end: {$this->value}\n"; + } +} + +echo "Starting test\n"; + +// Create object that will be garbage collected +$obj = new TestObject("test-object"); + +// Remove reference so object becomes eligible for GC +unset($obj); + +echo "After unset\n"; + +// Force garbage collection +gc_collect_cycles(); + +echo "After GC\n"; + +// Start a coroutine to continue execution after destructor suspend +spawn(function() { + echo "Test complete\n"; +}); + +?> +--EXPECT-- +Starting test +Created: test-object +Destructor start: test-object +Suspended in destructor: test-object +Destructor end: test-object +After unset +After GC +Test complete \ No newline at end of file diff --git a/tests/gc/002-gc_destructor_spawn_coroutine.phpt b/tests/gc/002-gc_destructor_spawn_coroutine.phpt new file mode 100644 index 0000000..d7cea1b --- /dev/null +++ b/tests/gc/002-gc_destructor_spawn_coroutine.phpt @@ -0,0 +1,65 @@ +--TEST-- +GC 002: Spawn new coroutine in destructor +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + echo "Destructor start: {$this->value}\n"; + + // Spawn new coroutine in destructor - this is the key test scenario + spawn(function() { + echo "Spawned coroutine running\n"; + suspend(); + echo "Spawned coroutine complete\n"; + }); + + echo "Coroutine spawned in destructor: {$this->value}\n"; + + echo "Destructor end: {$this->value}\n"; + } +} + +echo "Starting test\n"; + +// Create object that will be garbage collected +$obj = new TestObject("test-object"); + +// Remove reference so object becomes eligible for GC +unset($obj); + +echo "After unset\n"; + +// Force garbage collection +gc_collect_cycles(); + +echo "After GC\n"; + +// Continue execution to let spawned coroutines complete +spawn(function() { + echo "Test complete\n"; +}); + +?> +--EXPECT-- +Starting test +Created: test-object +Destructor start: test-object +Coroutine spawned in destructor: test-object +Destructor end: test-object +After unset +After GC +Spawned coroutine running +Test complete +Spawned coroutine complete \ No newline at end of file diff --git a/tests/gc/003-gc_destructor_resume_other.phpt b/tests/gc/003-gc_destructor_resume_other.phpt new file mode 100644 index 0000000..259cd19 --- /dev/null +++ b/tests/gc/003-gc_destructor_resume_other.phpt @@ -0,0 +1,77 @@ +--TEST-- +GC 003: Resume other coroutine from destructor +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + global $suspended_coroutine; + + echo "Destructor start: {$this->value}\n"; + + // Resume the previously suspended coroutine + if ($suspended_coroutine !== null) { + echo "Resuming other coroutine from destructor\n"; + $result = await($suspended_coroutine); + echo "Other coroutine result: {$result}\n"; + } + + echo "Destructor end: {$this->value}\n"; + } +} + +echo "Starting test\n"; + +// Start a coroutine that will be suspended +$suspended_coroutine = spawn(function() { + echo "Other coroutine start\n"; + suspend(); // Simulate async work + echo "Other coroutine end\n"; + return "other-result"; +}); + +// Create object that will be garbage collected +$obj = new TestObject("test-object"); + +// Remove reference so object becomes eligible for GC +unset($obj); + +echo "After unset\n"; + +// Force garbage collection - this will trigger destructor +gc_collect_cycles(); + +echo "After GC\n"; + +// Continue execution +spawn(function() { + echo "Test complete\n"; +}); + +?> +--EXPECT-- +Starting test +Created: test-object +Destructor start: test-object +Resuming other coroutine from destructor +Other coroutine start +Other coroutine end +Other coroutine result: other-result +Destructor end: test-object +After unset +After GC +Test complete \ No newline at end of file diff --git a/tests/gc/004-gc_destructor_exception_with_suspend.phpt b/tests/gc/004-gc_destructor_exception_with_suspend.phpt new file mode 100644 index 0000000..1968a75 --- /dev/null +++ b/tests/gc/004-gc_destructor_exception_with_suspend.phpt @@ -0,0 +1,78 @@ +--TEST-- +GC 004: Exception handling with suspend in destructor +--FILE-- +value = $value; + $this->should_throw = $should_throw; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + echo "Destructor start: {$this->value}\n"; + + try { + // Suspend in destructor + echo "Suspended in destructor: {$this->value}\n"; + suspend(); + + if ($this->should_throw) { + throw new Exception("Test exception after suspend"); + } + + echo "Destructor middle: {$this->value}\n"; + + } catch (Exception $e) { + echo "Exception caught in destructor: {$e->getMessage()}\n"; + } + + echo "Destructor end: {$this->value}\n"; + } +} + +spawn(function() { + echo "Starting test\n"; + + // Test 1: Normal case without exception + echo "=== Test 1: Normal case ===\n"; + $obj1 = new TestObject("normal", false); + unset($obj1); + gc_collect_cycles(); + + suspend(); + + // Test 2: Exception case + echo "=== Test 2: Exception case ===\n"; + $obj2 = new TestObject("exception", true); + unset($obj2); + gc_collect_cycles(); + + suspend(); + + echo "Test complete\n"; +}); + +?> +--EXPECT-- +Starting test +=== Test 1: Normal case === +Created: normal +Destructor start: normal +Suspended in destructor: normal +Destructor middle: normal +Destructor end: normal +=== Test 2: Exception case === +Created: exception +Destructor start: exception +Suspended in destructor: exception +Exception caught in destructor: Test exception after suspend +Destructor end: exception +Test complete \ No newline at end of file diff --git a/tests/gc/005-gc_destructor_cycles_simple.phpt b/tests/gc/005-gc_destructor_cycles_simple.phpt new file mode 100644 index 0000000..a59086c --- /dev/null +++ b/tests/gc/005-gc_destructor_cycles_simple.phpt @@ -0,0 +1,73 @@ +--TEST-- +GC 005: Simple circular references with suspend in destructor +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + echo "Destructor start: {$this->value}\n"; + echo "Suspended in destructor: {$this->value}\n"; + suspend(); + echo "Destructor end: {$this->value}\n"; + } + + public function setRef($ref) { + $this->ref = $ref; + } +} + +echo "Starting test\n"; + +// Create circular reference +$obj1 = new TestObject("object-A"); +$obj2 = new TestObject("object-B"); + +// Create cycle: A -> B -> A +$obj1->setRef($obj2); +$obj2->setRef($obj1); + +echo "Created circular reference\n"; + +// Remove references so objects become eligible for GC +unset($obj1, $obj2); + +echo "After unset\n"; + +// Force garbage collection +$collected = gc_collect_cycles(); +echo "GC collected cycles: {$collected}\n"; + +echo "After GC\n"; + +// Continue execution +spawn(function() { + echo "Test complete\n"; +}); + +?> +--EXPECT-- +Starting test +Created: object-A +Created: object-B +Created circular reference +After unset +Destructor start: object-A +Suspended in destructor: object-A +Destructor end: object-A +Destructor start: object-B +Suspended in destructor: object-B +Destructor end: object-B +GC collected cycles: 2 +After GC +Test complete \ No newline at end of file diff --git a/tests/gc/006-gc_destructor_simple.phpt b/tests/gc/006-gc_destructor_simple.phpt new file mode 100644 index 0000000..007be83 --- /dev/null +++ b/tests/gc/006-gc_destructor_simple.phpt @@ -0,0 +1,40 @@ +--TEST-- +GC 006: Simple destructor test without async operations +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + echo "Destructor: {$this->value}\n"; + } +} + +echo "Starting test\n"; + +// Create object that will be garbage collected +$obj = new TestObject("test-object"); + +// Remove reference so object becomes eligible for GC +unset($obj); + +echo "After unset\n"; + +// Force garbage collection +gc_collect_cycles(); + +echo "Test complete\n"; + +?> +--EXPECT-- +Starting test +Created: test-object +Destructor: test-object +After unset +Test complete \ No newline at end of file diff --git a/tests/gc/007-gc_destructor_complex_async_ops.phpt b/tests/gc/007-gc_destructor_complex_async_ops.phpt new file mode 100644 index 0000000..0c60684 --- /dev/null +++ b/tests/gc/007-gc_destructor_complex_async_ops.phpt @@ -0,0 +1,99 @@ +--TEST-- +GC 007: Complex async operations in destructor +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + global $global_coroutines; + + echo "Destructor start: {$this->value}\n"; + + // Complex async orchestration: spawn + suspend + await + + // 1. Spawn a new coroutine + $spawned = spawn(function() { + echo "Spawned coroutine start\n"; + suspend(); + echo "Spawned coroutine end\n"; + return "spawned-result"; + }); + + $global_coroutines[] = $spawned; + + // 2. Suspend current execution + echo "Suspended in destructor: {$this->value}\n"; + suspend(); + + // 3. Resume and wait for the spawned coroutine + echo "Resuming in destructor: {$this->value}\n"; + $result = await($spawned); + echo "Spawned result: {$result}\n"; + + // 4. Spawn another coroutine but don't wait for it + $background = spawn(function() { + suspend(); + echo "Background coroutine complete\n"; + return "background-result"; + }); + + $global_coroutines[] = $background; + + echo "Destructor end: {$this->value}\n"; + } +} + +echo "Starting test\n"; + +// Create object that will be garbage collected +$obj = new TestObject("complex-object"); + +// Remove reference so object becomes eligible for GC +unset($obj); + +echo "After unset\n"; + +// Force garbage collection +gc_collect_cycles(); + +echo "After GC\n"; + +// Wait for background coroutines to complete +foreach ($global_coroutines as $coro) { + $result = await($coro); + echo "Final result: {$result}\n"; +} + +echo "Test complete\n"; + +?> +--EXPECT-- +Starting test +Created: complex-object +Destructor start: complex-object +Suspended in destructor: complex-object +Spawned coroutine start +Resuming in destructor: complex-object +Spawned coroutine end +Spawned result: spawned-result +Destructor end: complex-object +After unset +After GC +Final result: spawned-result +Background coroutine complete +Final result: background-result +Test complete \ No newline at end of file diff --git a/tests/gc/008-gc_destructor_object_resurrection.phpt b/tests/gc/008-gc_destructor_object_resurrection.phpt new file mode 100644 index 0000000..022841c --- /dev/null +++ b/tests/gc/008-gc_destructor_object_resurrection.phpt @@ -0,0 +1,93 @@ +--TEST-- +GC 008: Object resurrection through suspended destructor +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + global $global_resurrection_storage; + + echo "Destructor start: {$this->value}\n"; + + // Suspend in destructor and "resurrect" the object by storing a reference + echo "Suspended in destructor: {$this->value}\n"; + + // "Resurrect" by storing reference to self + echo "Resurrecting object: {$this->value}\n"; + $global_resurrection_storage[] = $this; + + suspend(); + + echo "Destructor end: {$this->value}\n"; + } + + public function doSomething() { + echo "Object {$this->value} is alive and working!\n"; + } +} + +echo "Starting test\n"; + +// Create object that should be garbage collected +$obj = new TestObject("zombie-object"); + +// Remove local reference so object becomes eligible for GC +unset($obj); + +echo "After unset\n"; + +// Force garbage collection - this should trigger destructor +gc_collect_cycles(); + +echo "After GC\n"; + +// Check if object was "resurrected" +echo "Checking resurrection storage...\n"; +if (!empty($global_resurrection_storage)) { + echo "Found resurrected objects: " . count($global_resurrection_storage) . "\n"; + + foreach ($global_resurrection_storage as $resurrected) { + echo "Resurrected object value: {$resurrected->value}\n"; + $resurrected->doSomething(); + } +} else { + echo "No objects were resurrected\n"; +} + +// Force another GC to clean up resurrected objects +$global_resurrection_storage = []; +gc_collect_cycles(); + +// Continue execution +spawn(function() { + echo "Test complete\n"; +}); + +?> +--EXPECT-- +Starting test +Created: zombie-object +Destructor start: zombie-object +Suspended in destructor: zombie-object +Resurrecting object: zombie-object +Destructor end: zombie-object +After unset +After GC +Checking resurrection storage... +Found resurrected objects: 1 +Resurrected object value: zombie-object +Object zombie-object is alive and working! +Test complete \ No newline at end of file diff --git a/tests/gc/009-gc_destructor_shutdown_sequence.phpt b/tests/gc/009-gc_destructor_shutdown_sequence.phpt new file mode 100644 index 0000000..2bde015 --- /dev/null +++ b/tests/gc/009-gc_destructor_shutdown_sequence.phpt @@ -0,0 +1,96 @@ +--TEST-- +GC 009: Async operations in destructor during shutdown +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + echo "Destructor start: {$this->value}\n"; + + try { + // Try to suspend during shutdown - this tests shutdown behavior + echo "Suspended in destructor during shutdown: {$this->value}\n"; + + // Try to spawn another coroutine during shutdown + try { + $spawned = spawn(function() { + echo "Spawned during shutdown\n"; + return "shutdown-spawn-result"; + }); + + $result = await($spawned); + echo "Shutdown spawn result: {$result}\n"; + } catch (Exception $e) { + echo "Exception during shutdown spawn: {$e->getMessage()}\n"; + } + + suspend(); + echo "Suspend completed during shutdown: {$this->value}\n"; + + } catch (Exception $e) { + echo "Exception in destructor during shutdown: {$e->getMessage()}\n"; + } + + echo "Destructor end: {$this->value}\n"; + } +} + +// Register shutdown function to create objects during shutdown +register_shutdown_function(function() { + echo "=== Shutdown function start ===\n"; + + // Create object during shutdown - its destructor will run during shutdown + $shutdown_obj = new TestObject("shutdown-object"); + unset($shutdown_obj); // This will trigger destructor during shutdown + + echo "=== Shutdown function end ===\n"; +}); + +echo "Starting test\n"; + +// Create object that will be cleaned up normally (before shutdown) +$normal_obj = new TestObject("normal-object"); +unset($normal_obj); +gc_collect_cycles(); + +echo "Normal cleanup complete\n"; + +// Continue execution +spawn(function() { + echo "Main test complete\n"; + + // Script ends here, shutdown functions will run +}); + +?> +--EXPECT-- +Starting test +Created: normal-object +Destructor start: normal-object +Suspended in destructor during shutdown: normal-object +Spawned during shutdown +Shutdown spawn result: shutdown-spawn-result +Suspend completed during shutdown: normal-object +Destructor end: normal-object +Normal cleanup complete +Main test complete +=== Shutdown function start === +Created: shutdown-object +Destructor start: shutdown-object +Suspended in destructor during shutdown: shutdown-object +Spawned during shutdown +Shutdown spawn result: shutdown-spawn-result +Suspend completed during shutdown: shutdown-object +Destructor end: shutdown-object +=== Shutdown function end === \ No newline at end of file diff --git a/tests/gc/010-gc_destructor_force_close_error.phpt b/tests/gc/010-gc_destructor_force_close_error.phpt new file mode 100644 index 0000000..b96fd1d --- /dev/null +++ b/tests/gc/010-gc_destructor_force_close_error.phpt @@ -0,0 +1,108 @@ +--TEST-- +GC 010: Errors when async operations in terminated coroutines +--FILE-- +value = $value; + $this->is_terminated = $is_terminated; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + echo "Destructor start: {$this->value}\n"; + + if ($this->is_terminated) { + echo "Attempting async operation in terminated context\n"; + + try { + // This should fail in a terminated/force-closed context + echo "This should not execute in terminated context\n"; + suspend(); + + echo "ERROR: Suspend succeeded when it should have failed\n"; + + } catch (Exception $e) { + echo "Expected exception: {$e->getMessage()}\n"; + } + + try { + // This should also fail in a terminated context + $spawned = spawn(function() { + echo "This spawn should not execute in terminated context\n"; + return "should-not-happen"; + }); + + echo "ERROR: Spawn succeeded when it should have failed\n"; + + } catch (Exception $e) { + echo "Expected spawn exception: {$e->getMessage()}\n"; + } + + } else { + // Normal async operation should work + echo "Normal async operation\n"; + + echo "Normal suspend working: {$this->value}\n"; + suspend(); + } + + echo "Destructor end: {$this->value}\n"; + } +} + +// Simulate different execution contexts +echo "Starting test\n"; + +// Test 1: Normal context (should work) +echo "=== Test 1: Normal context ===\n"; +$normal_obj = new TestObject("normal"); +unset($normal_obj); +gc_collect_cycles(); + +// Test 2: Create object in a context that will be terminated +echo "=== Test 2: Terminated context ===\n"; + +$terminated_coroutine = spawn(function() { + // Create object that will be destructed when this coroutine is cancelled + $terminated_obj = new TestObject("terminated", true); + + // This coroutine will be cancelled, causing force-close behavior + suspend(); // Will be interrupted + + return "never-reached"; +}); + +// Cancel the coroutine +$terminated_coroutine->cancel(); + +// Try to await the cancelled coroutine +try { + await($terminated_coroutine); +} catch (Exception $e) { + echo "Coroutine cancelled: {$e->getMessage()}\n"; +} + +// Force GC to clean up objects from terminated context +gc_collect_cycles(); + +echo "Test complete\n"; + +?> +--EXPECT-- +Starting test +=== Test 1: Normal context === +Created: normal +Destructor start: normal +Normal async operation +Normal suspend working: normal +Destructor end: normal +=== Test 2: Terminated context === \ No newline at end of file diff --git a/tests/gc/011-gc_destructor_cycles_with_suspend.phpt b/tests/gc/011-gc_destructor_cycles_with_suspend.phpt new file mode 100644 index 0000000..3f6a988 --- /dev/null +++ b/tests/gc/011-gc_destructor_cycles_with_suspend.phpt @@ -0,0 +1,88 @@ +--TEST-- +GC 005: Circular references with suspend in destructor +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + echo "Destructor start: {$this->value}\n"; + + // Suspend in destructor - this is where the GC gets complex + echo "Suspended in destructor: {$this->value}\n"; + + // Check if we still have a reference during GC + if ($this->ref !== null) { + echo "Still has reference to: {$this->ref->value}\n"; + } else { + echo "Reference is null during GC\n"; + } + + suspend(); + + echo "Destructor end: {$this->value}\n"; + } + + public function setRef($ref) { + $this->ref = $ref; + } +} + +spawn(function() { + echo "Starting test\n"; + + // Create circular reference + $obj1 = new TestObject("object-A"); + $obj2 = new TestObject("object-B"); + + // Create cycle: A -> B -> A + $obj1->setRef($obj2); + $obj2->setRef($obj1); + + echo "Created circular reference\n"; + + // Remove references so objects become eligible for GC + unset($obj1, $obj2); + + echo "After unset\n"; + + // Force garbage collection - this should handle the cycle with suspended destructors + $collected = gc_collect_cycles(); + echo "GC collected cycles: {$collected}\n"; + + echo "After GC\n"; + + // Small suspend to let any remaining async operations complete + suspend(); + + echo "Test complete\n"; +}); + +?> +--EXPECT-- +Starting test +Created: object-A +Created: object-B +Created circular reference +After unset +GC collected cycles: 0 +After GC +Destructor start: object-B +Suspended in destructor: object-B +Still has reference to: object-A +Test complete +Destructor end: object-B +Destructor start: object-A +Suspended in destructor: object-A +Still has reference to: object-B +Destructor end: object-A \ No newline at end of file diff --git a/tests/gc/012-gc_destructor_multiple_gc_cycles.phpt b/tests/gc/012-gc_destructor_multiple_gc_cycles.phpt new file mode 100644 index 0000000..56c55e9 --- /dev/null +++ b/tests/gc/012-gc_destructor_multiple_gc_cycles.phpt @@ -0,0 +1,98 @@ +--TEST-- +GC 006: Multiple GC cycles with suspended destructors +--FILE-- +value = $value; + echo "Created: {$this->value}\n"; + } + + public function __destruct() { + self::$destructor_count++; + echo "Destructor start: {$this->value} (count: " . self::$destructor_count . ")\n"; + + // Suspend in destructor + echo "Suspended in destructor: {$this->value}\n"; + suspend(); + echo "Destructor end: {$this->value}\n"; + } + + public function setRef($ref) { + $this->ref = $ref; + } +} + +echo "Starting test\n"; + +// First batch - create objects that will suspend in destructor +echo "=== Creating first batch ===\n"; +$obj1 = new TestObject("batch1-A"); +$obj2 = new TestObject("batch1-B"); +$obj1->setRef($obj2); +$obj2->setRef($obj1); + +unset($obj1, $obj2); + +// Trigger first GC cycle +echo "=== First GC cycle ===\n"; +gc_collect_cycles(); + +// Second batch +echo "=== Creating second batch ===\n"; +$obj3 = new TestObject("batch2-A"); +$obj4 = new TestObject("batch2-B"); +$obj3->setRef($obj4); +$obj4->setRef($obj3); + +unset($obj3, $obj4); + +// Trigger second GC cycle +echo "=== Second GC cycle ===\n"; +gc_collect_cycles(); + +// Third GC cycle to clean up anything remaining +echo "=== Third GC cycle ===\n"; +gc_collect_cycles(); + +echo "Total destructors called: " . TestObject::$destructor_count . "\n"; + +// Continue execution +spawn(function() { + echo "Test complete\n"; +}); + +?> +--EXPECT-- +Starting test +=== Creating first batch === +Created: batch1-A +Created: batch1-B +=== First GC cycle === +Destructor start: batch1-A (count: 1) +Suspended in destructor: batch1-A +Destructor end: batch1-A +Destructor start: batch1-B (count: 2) +Suspended in destructor: batch1-B +Destructor end: batch1-B +=== Creating second batch === +Created: batch2-A +Created: batch2-B +=== Second GC cycle === +=== Third GC cycle === +Total destructors called: 2 +Destructor start: batch2-A (count: 3) +Suspended in destructor: batch2-A +Test complete +Destructor end: batch2-A +Destructor start: batch2-B (count: 4) +Suspended in destructor: batch2-B +Destructor end: batch2-B \ No newline at end of file diff --git a/tests/gc/README.md b/tests/gc/README.md new file mode 100644 index 0000000..ab566c4 --- /dev/null +++ b/tests/gc/README.md @@ -0,0 +1,134 @@ +# Garbage Collection Tests for Destructors with Async Operations + +This directory contains comprehensive tests for garbage collection behavior when destructors contain asynchronous operations. These tests are inspired by fiber destructor tests in `Zend/tests/fibers/` and adapted for the TrueAsync API. + +## Test Overview + +### Core Async Operations in Destructors + +- **001-gc_destructor_basic_suspend.phpt** - Basic `Async::suspend()` call in destructor +- **002-gc_destructor_spawn_coroutine.phpt** - Creating new coroutines with `Async::spawn()` in destructor +- **003-gc_destructor_resume_other.phpt** - Resuming other suspended coroutines from destructor + +### Error Handling and Edge Cases + +- **004-gc_destructor_exception_with_suspend.phpt** - Exception handling with suspended destructors +- **010-gc_destructor_force_close_error.phpt** - Error behavior in terminated/cancelled coroutines + +### Complex GC Scenarios + +- **005-gc_destructor_cycles_with_suspend.phpt** - Circular references with suspended destructors +- **006-gc_destructor_multiple_gc_cycles.phpt** - Multiple GC cycles with suspended destructors +- **007-gc_destructor_complex_async_ops.phpt** - Complex async orchestration (spawn + suspend + resume) + +### Advanced Scenarios + +- **008-gc_destructor_object_resurrection.phpt** - Object "resurrection" through suspended destructors +- **009-gc_destructor_shutdown_sequence.phpt** - Async operations during PHP shutdown sequence + +## Key Test Patterns + +### 1. **Basic Suspension Pattern** +```php +public function __destruct() { + Async::suspend(function($resolve) { + // Async work here + $resolve("result"); + }); +} +``` + +### 2. **Coroutine Spawning Pattern** +```php +public function __destruct() { + $coroutine = Async::spawn(function() { + // New coroutine work + return "result"; + }); + $result = Async::await($coroutine); +} +``` + +### 3. **Circular Reference Pattern** +```php +// Objects A and B reference each other +$objA->ref = $objB; +$objB->ref = $objA; +unset($objA, $objB); +gc_collect_cycles(); // Triggers destructors with cycles +``` + +### 4. **Exception Handling Pattern** +```php +public function __destruct() { + try { + Async::suspend(/* ... */); + } catch (Exception $e) { + // Handle async exceptions in destructor + } +} +``` + +## What These Tests Verify + +### ✅ **Coroutine GC Handler Integration** +- Proper registration of `async_coroutine_object_gc()` function +- Correct ZVAL tracking for all coroutine structures +- Execution stack traversal for suspended coroutines + +### ✅ **GC Cycle Detection with Async** +- Detection of cycles involving coroutines with async destructors +- Proper cleanup of circular references when destructors suspend +- Multi-cycle GC behavior with long-running suspended destructors + +### ✅ **Destructor Suspension Handling** +- `zend_gc_collect_cycles_coroutine()` integration +- Concurrent iterator behavior with suspended destructors +- State preservation across GC cycles + +### ✅ **Error Recovery** +- Exception propagation from suspended destructors +- Force-close error handling in terminated coroutines +- Graceful degradation during shutdown sequences + +### ✅ **Memory Safety** +- No memory leaks with suspended destructors +- Proper cleanup of interceptor state +- Context and scope cleanup integration + +## Technical Implementation Details + +These tests exercise the following core GC mechanisms: + +1. **`async_coroutine_object_gc()`** - The GC handler that tracks all ZVAL references in coroutine structures +2. **`zend_gc_collect_cycles_coroutine()`** - Coroutine-aware garbage collection +3. **Interceptor GC integration** - Cleanup of coroutine switch handlers +4. **Context GC integration** - Cleanup of async context data + +## Running the Tests + +```bash +# Run all GC tests +php run-tests.php ext/async/tests/gc/ + +# Run specific test +php run-tests.php ext/async/tests/gc/001-gc_destructor_basic_suspend.phpt + +# Run with verbose output +php run-tests.php -v ext/async/tests/gc/ +``` + +## Test Dependencies + +These tests require: +- PHP with async extension loaded +- Proper GC handler registration (`coroutine_handlers.get_gc = async_coroutine_object_gc`) +- Working coroutine interceptor system +- Functional `zend_gc_collect_cycles_coroutine()` implementation + +## Related Documentation + +- **TrueAsync API**: `docs/source/true_async_api/` +- **Core GC Tests**: `Zend/tests/gc/` +- **Fiber GC Tests**: `Zend/tests/fibers/destructors_*.phpt` +- **Async Extension Tests**: `ext/async/tests/coroutine/` \ No newline at end of file