From 14af3cb2f40431f9689ad7a1988f9ceef875faa3 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+EdmondDantes@users.noreply.github.com>
Date: Thu, 16 Jul 2026 11:01:38 +0300
Subject: [PATCH 01/33] async: coroutine engine core, the reference scheduler
and the coroutine context
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Async Core ABI (zend_async_API.h): coroutines with a packed lifecycle,
scheduler slots (launch, enqueue-with-error, suspend, cancel, await),
switch/finish handler vectors, the awaiting-info vector, microtasks, and
the coroutine context — an embedded internal store for C extensions
(process-unique numeric keys) plus the userland zend_async_context_t
storage behind a provider-registered class. Fibers run as coroutines under
a scheduler; gc_collect_cycles() stays synchronous over a GC coroutine;
shutdown drains coroutines gracefully; exit() in a fiber raises the
shutdown notification.
ext/test_scheduler validates the ABI in-tree: the C reference provider,
gated by test_scheduler.enable. The PHP registration bridge lives out of
tree (ext-scheduler-hook) and talks to the core through ZEND_API only.
Upstream suites stay byte-for-byte and run schedulerless; the
scheduler-adapted duplicates and the provider suite opt in.
---
.../gc-generator-in-suspended-fiber.phpt | 42 +
Zend/zend.c | 9 +-
Zend/zend_async_API.c | 744 +++++++
Zend/zend_async_API.h | 698 ++++++
Zend/zend_execute_API.c | 125 +-
Zend/zend_fibers.c | 562 ++++-
Zend/zend_fibers.h | 30 +
Zend/zend_gc.c | 272 ++-
Zend/zend_globals.h | 19 +
Zend/zend_objects_API.c | 111 +
Zend/zend_objects_API.h | 1 +
configure.ac | 1 +
ext/test_scheduler/config.m4 | 13 +
ext/test_scheduler/config.w32 | 6 +
ext/test_scheduler/php_test_scheduler.h | 22 +
ext/test_scheduler/test_scheduler.c | 1964 +++++++++++++++++
ext/test_scheduler/test_scheduler.stub.php | 74 +
ext/test_scheduler/test_scheduler_arginfo.h | 111 +
.../tests/001_spawn_after_main.phpt | 22 +
.../tests/002_suspend_resume.phpt | 27 +
.../tests/003_result_and_exception.phpt | 34 +
ext/test_scheduler/tests/004_deadlock.phpt | 21 +
.../tests/005_main_is_a_coroutine.phpt | 30 +
.../tests/006_nested_spawn.phpt | 23 +
.../tests/007_generator_across_suspend.phpt | 46 +
.../tests/008_generator_running_guard.phpt | 37 +
.../tests/009_generator_shutdown_unwind.phpt | 46 +
.../010_generator_in_fiber_shutdown.phpt | 36 +
.../011_unhandled_exception_surfaces.phpt | 31 +
ext/test_scheduler/tests/012_await_basic.phpt | 44 +
.../tests/013_await_multiple_waiters.phpt | 30 +
.../tests/014_await_errors.phpt | 41 +
.../tests/015_deadlock_mutual_await.phpt | 51 +
.../tests/016_deadlock_caught.phpt | 50 +
.../tests/017_deadlock_after_main.phpt | 43 +
ext/test_scheduler/tests/018_cancel.phpt | 70 +
ext/test_scheduler/tests/019_await_main.phpt | 27 +
.../tests/020_gc_await_cancel.phpt | 46 +
ext/test_scheduler/tests/021_gc_deadlock.phpt | 33 +
.../tests/022_fiber_status_await.phpt | 43 +
.../tests/023_gc_parked_frame_temps.phpt | 47 +
.../tests/024_gc_coroutine_weakref.phpt | 95 +
.../tests/025_awaiting_info.phpt | 84 +
.../tests/026_dtor_fiber_suspend.phpt | 61 +
.../tests/027_dtor_fiber_start.phpt | 39 +
.../tests/034_gc_inside_fiber.phpt | 37 +
.../tests/035_gc_fiber_generator_dtors.phpt | 35 +
.../036_shutdown_generator_in_fiber.phpt | 32 +
.../tests/037_gc_dtor_iterator_loop.phpt | 42 +
.../tests/038_gc_dtor_iterator_loop_2.phpt | 43 +
.../tests/039_oom_recursive_fiber.phpt | 29 +
.../040_force_close_catch_dtor_throw.phpt | 41 +
.../041_throw_during_fiber_destruct.phpt | 29 +
.../tests/042_unfinished_fiber_finally.phpt | 38 +
.../043_unfinished_fiber_nested_try.phpt | 55 +
...4_unfinished_fiber_suspend_in_finally.phpt | 44 +
...045_unfinished_fiber_throw_in_finally.phpt | 56 +
.../tests/046_gc_nested_data_after_dtors.phpt | 44 +
.../tests/047_gc_uaf_dtor_order.phpt | 35 +
.../tests/048_gc_temp_result_cycle.phpt | 22 +
.../tests/049_gc_exceptions_in_dtors.phpt | 37 +
.../tests/050_gc_nested_references.phpt | 39 +
.../tests/051_gc_props_ht_nested.phpt | 33 +
.../tests/052_gc_buffer_reuse.phpt | 49 +
.../tests/053_generator_closure_this.phpt | 24 +
.../tests/054_generator_nonscalar_keys.phpt | 56 +
.../tests/055_generator_frames_scan_1.phpt | 61 +
.../tests/056_generator_frames_scan_2.phpt | 56 +
.../tests/057_generator_frames_scan_3.phpt | 57 +
.../tests/058_generator_frames_scan_4.phpt | 60 +
.../059_generator_shutdown_dtor_order.phpt | 57 +
...060_shutdown_dtor_suspend_object_pass.phpt | 44 +
.../061_shutdown_dtor_pass_owner_killed.phpt | 62 +
.../tests/062_gc_spawn_named_arg_cycle.phpt | 28 +
.../tests/063_spawn_context_failure.phpt | 28 +
.../tests/064_exit_in_fiber_shutdown.phpt | 21 +
.../tests/065_fiber_disposal_no_shutdown.phpt | 27 +
...066_exit_in_coroutine_scheduler_owned.phpt | 23 +
.../tests/067_exit_in_main_no_shutdown.phpt | 16 +
main/main.c | 27 +-
win32/build/config.w32 | 2 +-
81 files changed, 7282 insertions(+), 68 deletions(-)
create mode 100644 Zend/tests/fibers/gc-generator-in-suspended-fiber.phpt
create mode 100644 Zend/zend_async_API.c
create mode 100644 Zend/zend_async_API.h
create mode 100644 ext/test_scheduler/config.m4
create mode 100644 ext/test_scheduler/config.w32
create mode 100644 ext/test_scheduler/php_test_scheduler.h
create mode 100644 ext/test_scheduler/test_scheduler.c
create mode 100644 ext/test_scheduler/test_scheduler.stub.php
create mode 100644 ext/test_scheduler/test_scheduler_arginfo.h
create mode 100644 ext/test_scheduler/tests/001_spawn_after_main.phpt
create mode 100644 ext/test_scheduler/tests/002_suspend_resume.phpt
create mode 100644 ext/test_scheduler/tests/003_result_and_exception.phpt
create mode 100644 ext/test_scheduler/tests/004_deadlock.phpt
create mode 100644 ext/test_scheduler/tests/005_main_is_a_coroutine.phpt
create mode 100644 ext/test_scheduler/tests/006_nested_spawn.phpt
create mode 100644 ext/test_scheduler/tests/007_generator_across_suspend.phpt
create mode 100644 ext/test_scheduler/tests/008_generator_running_guard.phpt
create mode 100644 ext/test_scheduler/tests/009_generator_shutdown_unwind.phpt
create mode 100644 ext/test_scheduler/tests/010_generator_in_fiber_shutdown.phpt
create mode 100644 ext/test_scheduler/tests/011_unhandled_exception_surfaces.phpt
create mode 100644 ext/test_scheduler/tests/012_await_basic.phpt
create mode 100644 ext/test_scheduler/tests/013_await_multiple_waiters.phpt
create mode 100644 ext/test_scheduler/tests/014_await_errors.phpt
create mode 100644 ext/test_scheduler/tests/015_deadlock_mutual_await.phpt
create mode 100644 ext/test_scheduler/tests/016_deadlock_caught.phpt
create mode 100644 ext/test_scheduler/tests/017_deadlock_after_main.phpt
create mode 100644 ext/test_scheduler/tests/018_cancel.phpt
create mode 100644 ext/test_scheduler/tests/019_await_main.phpt
create mode 100644 ext/test_scheduler/tests/020_gc_await_cancel.phpt
create mode 100644 ext/test_scheduler/tests/021_gc_deadlock.phpt
create mode 100644 ext/test_scheduler/tests/022_fiber_status_await.phpt
create mode 100644 ext/test_scheduler/tests/023_gc_parked_frame_temps.phpt
create mode 100644 ext/test_scheduler/tests/024_gc_coroutine_weakref.phpt
create mode 100644 ext/test_scheduler/tests/025_awaiting_info.phpt
create mode 100644 ext/test_scheduler/tests/026_dtor_fiber_suspend.phpt
create mode 100644 ext/test_scheduler/tests/027_dtor_fiber_start.phpt
create mode 100644 ext/test_scheduler/tests/034_gc_inside_fiber.phpt
create mode 100644 ext/test_scheduler/tests/035_gc_fiber_generator_dtors.phpt
create mode 100644 ext/test_scheduler/tests/036_shutdown_generator_in_fiber.phpt
create mode 100644 ext/test_scheduler/tests/037_gc_dtor_iterator_loop.phpt
create mode 100644 ext/test_scheduler/tests/038_gc_dtor_iterator_loop_2.phpt
create mode 100644 ext/test_scheduler/tests/039_oom_recursive_fiber.phpt
create mode 100644 ext/test_scheduler/tests/040_force_close_catch_dtor_throw.phpt
create mode 100644 ext/test_scheduler/tests/041_throw_during_fiber_destruct.phpt
create mode 100644 ext/test_scheduler/tests/042_unfinished_fiber_finally.phpt
create mode 100644 ext/test_scheduler/tests/043_unfinished_fiber_nested_try.phpt
create mode 100644 ext/test_scheduler/tests/044_unfinished_fiber_suspend_in_finally.phpt
create mode 100644 ext/test_scheduler/tests/045_unfinished_fiber_throw_in_finally.phpt
create mode 100644 ext/test_scheduler/tests/046_gc_nested_data_after_dtors.phpt
create mode 100644 ext/test_scheduler/tests/047_gc_uaf_dtor_order.phpt
create mode 100644 ext/test_scheduler/tests/048_gc_temp_result_cycle.phpt
create mode 100644 ext/test_scheduler/tests/049_gc_exceptions_in_dtors.phpt
create mode 100644 ext/test_scheduler/tests/050_gc_nested_references.phpt
create mode 100644 ext/test_scheduler/tests/051_gc_props_ht_nested.phpt
create mode 100644 ext/test_scheduler/tests/052_gc_buffer_reuse.phpt
create mode 100644 ext/test_scheduler/tests/053_generator_closure_this.phpt
create mode 100644 ext/test_scheduler/tests/054_generator_nonscalar_keys.phpt
create mode 100644 ext/test_scheduler/tests/055_generator_frames_scan_1.phpt
create mode 100644 ext/test_scheduler/tests/056_generator_frames_scan_2.phpt
create mode 100644 ext/test_scheduler/tests/057_generator_frames_scan_3.phpt
create mode 100644 ext/test_scheduler/tests/058_generator_frames_scan_4.phpt
create mode 100644 ext/test_scheduler/tests/059_generator_shutdown_dtor_order.phpt
create mode 100644 ext/test_scheduler/tests/060_shutdown_dtor_suspend_object_pass.phpt
create mode 100644 ext/test_scheduler/tests/061_shutdown_dtor_pass_owner_killed.phpt
create mode 100644 ext/test_scheduler/tests/062_gc_spawn_named_arg_cycle.phpt
create mode 100644 ext/test_scheduler/tests/063_spawn_context_failure.phpt
create mode 100644 ext/test_scheduler/tests/064_exit_in_fiber_shutdown.phpt
create mode 100644 ext/test_scheduler/tests/065_fiber_disposal_no_shutdown.phpt
create mode 100644 ext/test_scheduler/tests/066_exit_in_coroutine_scheduler_owned.phpt
create mode 100644 ext/test_scheduler/tests/067_exit_in_main_no_shutdown.phpt
diff --git a/Zend/tests/fibers/gc-generator-in-suspended-fiber.phpt b/Zend/tests/fibers/gc-generator-in-suspended-fiber.phpt
new file mode 100644
index 000000000000..609056a0151a
--- /dev/null
+++ b/Zend/tests/fibers/gc-generator-in-suspended-fiber.phpt
@@ -0,0 +1,42 @@
+--TEST--
+GC collects a cycle through a generator parked in Fiber::suspend() on a fiber stack
+--FILE--
+ frames -> $self -> fiber
+ yield 1;
+ Fiber::suspend("from gen");
+ yield 2;
+}
+
+$fiber = new Fiber(function () {
+ $g = gen();
+ $g->current();
+ $g->next(); // parks in Fiber::suspend() inside the generator
+ echo "unreachable\n";
+});
+
+$fiber->start();
+print "1\n";
+
+// Still referenced: nothing to collect.
+gc_collect_cycles();
+print "2\n";
+
+$fiber = null;
+gc_collect_cycles();
+print "3\n";
+?>
+--EXPECT--
+1
+2
+C::__destruct
+3
diff --git a/Zend/zend.c b/Zend/zend.c
index 07692db85196..c38269d32727 100644
--- a/Zend/zend.c
+++ b/Zend/zend.c
@@ -24,6 +24,7 @@
#include "zend_API.h"
#include "zend_exceptions.h"
#include "zend_builtin_functions.h"
+#include "zend_async_API.h"
#include "zend_ini.h"
#include "zend_vm.h"
#include "zend_dtrace.h"
@@ -831,6 +832,7 @@ static void executor_globals_ctor(zend_executor_globals *executor_globals) /* {{
executor_globals->current_fiber_context = NULL;
executor_globals->main_fiber_context = NULL;
executor_globals->active_fiber = NULL;
+ memset(&executor_globals->shutdown_context, 0, sizeof(executor_globals->shutdown_context));
#ifdef ZEND_WIN32
zend_get_windows_version_info(&executor_globals->windows_version_info);
#endif
@@ -1979,8 +1981,13 @@ ZEND_API zend_result zend_execute_script(int type, zval *retval, zend_file_handl
if (Z_TYPE(EG(user_exception_handler)) != IS_UNDEF) {
zend_user_exception_handler();
}
+
if (EG(exception)) {
- ret = zend_exception_error(EG(exception), E_ERROR);
+ if (ZEND_ASYNC_CURRENT_COROUTINE == NULL) {
+ ret = zend_exception_error(EG(exception), E_ERROR);
+ } else {
+ ret = FAILURE;
+ }
}
}
zend_destroy_static_vars(op_array);
diff --git a/Zend/zend_async_API.c b/Zend/zend_async_API.c
new file mode 100644
index 000000000000..02cdcb604bb6
--- /dev/null
+++ b/Zend/zend_async_API.c
@@ -0,0 +1,744 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Authors: Edmond |
+ +----------------------------------------------------------------------+
+*/
+#include "zend_async_API.h"
+#include "zend_exceptions.h"
+
+///////////////////////////////////////////////////////////////////
+/// Globals
+///////////////////////////////////////////////////////////////////
+
+#ifdef ZTS
+ZEND_API int zend_async_globals_id = 0;
+ZEND_API size_t zend_async_globals_offset = 0;
+#else
+ZEND_API zend_async_globals_t zend_async_globals_api = { 0 };
+#endif
+
+static void internal_globals_ctor(zend_async_globals_t *globals)
+{
+ memset(globals, 0, sizeof(zend_async_globals_t));
+}
+
+static void internal_globals_dtor(zend_async_globals_t *globals)
+{
+ (void) globals;
+}
+
+#ifdef ZTS
+static MUTEX_T scheduler_mutex = NULL;
+#endif
+
+void zend_async_globals_ctor(void)
+{
+#ifdef ZTS
+ scheduler_mutex = tsrm_mutex_alloc();
+
+ ts_allocate_fast_id(&zend_async_globals_id, &zend_async_globals_offset,
+ sizeof(zend_async_globals_t), (ts_allocate_ctor) internal_globals_ctor,
+ (ts_allocate_dtor) internal_globals_dtor);
+
+ ZEND_ASSERT(zend_async_globals_id != 0 && "zend_async_globals allocation failed");
+#else
+ internal_globals_ctor(&zend_async_globals_api);
+#endif
+}
+
+void zend_async_globals_dtor(void)
+{
+#ifndef ZTS
+ internal_globals_dtor(&zend_async_globals_api);
+#endif
+}
+
+///////////////////////////////////////////////////////////////////
+/// Internal context
+///////////////////////////////////////////////////////////////////
+
+/*
+ * The key registry maps a static C-string name to a process-unique numeric
+ * key. Keys are allocated once per process (typically at MINIT), hence the
+ * persistent memory and, under ZTS, the mutex.
+ */
+static HashTable *internal_context_key_names = NULL;
+static uint32_t internal_context_next_key = 1;
+#ifdef ZTS
+static MUTEX_T internal_context_mutex = NULL;
+#endif
+
+ZEND_API uint32_t zend_async_internal_context_key_alloc(const char *key_name)
+{
+#ifdef ZTS
+ if (internal_context_mutex == NULL) {
+ internal_context_mutex = tsrm_mutex_alloc();
+ }
+ tsrm_mutex_lock(internal_context_mutex);
+#endif
+
+ if (internal_context_key_names == NULL) {
+ internal_context_key_names = pemalloc(sizeof(HashTable), 1);
+ /* Values are static C strings owned by the callers — no destructor. */
+ zend_hash_init(internal_context_key_names, 8, NULL, NULL, 1);
+ }
+
+ /* The same static name gets the same key: a repeated alloc (a module
+ * started twice per process) must not mint a second identity. */
+ zend_ulong existing_key;
+ void *existing_name;
+ ZEND_HASH_FOREACH_NUM_KEY_PTR(internal_context_key_names, existing_key, existing_name)
+ {
+ if ((const char *) existing_name == key_name) {
+#ifdef ZTS
+ tsrm_mutex_unlock(internal_context_mutex);
+#endif
+ return (uint32_t) existing_key;
+ }
+ }
+ ZEND_HASH_FOREACH_END();
+
+ const uint32_t key = internal_context_next_key++;
+ zend_hash_index_add_new_ptr(internal_context_key_names, key, (void *) key_name);
+
+#ifdef ZTS
+ tsrm_mutex_unlock(internal_context_mutex);
+#endif
+
+ return key;
+}
+
+static void internal_context_keys_shutdown(void)
+{
+ if (internal_context_key_names != NULL) {
+ zend_hash_destroy(internal_context_key_names);
+ pefree(internal_context_key_names, 1);
+ internal_context_key_names = NULL;
+ }
+
+ internal_context_next_key = 1;
+
+#ifdef ZTS
+ if (internal_context_mutex != NULL) {
+ tsrm_mutex_free(internal_context_mutex);
+ internal_context_mutex = NULL;
+ }
+#endif
+}
+
+static zend_always_inline zend_coroutine_t *internal_context_coroutine(
+ zend_coroutine_t *coroutine)
+{
+ return coroutine != NULL ? coroutine : ZEND_ASYNC_CURRENT_COROUTINE;
+}
+
+ZEND_API zval *zend_async_internal_context_find(zend_coroutine_t *coroutine, uint32_t key)
+{
+ coroutine = internal_context_coroutine(coroutine);
+
+ if (coroutine == NULL) {
+ return NULL;
+ }
+
+ return zend_hash_index_find(&coroutine->internal_context, key);
+}
+
+ZEND_API bool zend_async_internal_context_set(
+ zend_coroutine_t *coroutine, uint32_t key, zval *value)
+{
+ coroutine = internal_context_coroutine(coroutine);
+
+ if (coroutine == NULL) {
+ return false;
+ }
+
+ Z_TRY_ADDREF_P(value);
+ zend_hash_index_update(&coroutine->internal_context, key, value);
+ return true;
+}
+
+ZEND_API bool zend_async_internal_context_unset(zend_coroutine_t *coroutine, uint32_t key)
+{
+ coroutine = internal_context_coroutine(coroutine);
+
+ if (coroutine == NULL) {
+ return false;
+ }
+
+ return zend_hash_index_del(&coroutine->internal_context, key) == SUCCESS;
+}
+
+ZEND_API void zend_async_internal_context_init(zend_coroutine_t *coroutine)
+{
+ /* The table is embedded to spare an allocation; zend_hash_init defers the
+ * bucket array to the first insert, so an unused context costs nothing. */
+ zend_hash_init(&coroutine->internal_context, 8, NULL, ZVAL_PTR_DTOR, false);
+}
+
+ZEND_API void zend_async_internal_context_destroy(zend_coroutine_t *coroutine)
+{
+ zend_hash_destroy(&coroutine->internal_context);
+}
+
+///////////////////////////////////////////////////////////////////
+/// Userland context
+///////////////////////////////////////////////////////////////////
+
+ZEND_API zend_async_new_context_t zend_async_new_context_fn = NULL;
+
+typedef struct {
+ zval key;
+ zval value;
+} async_context_entry_t;
+
+static void async_context_entry_dtor(zval *zv)
+{
+ async_context_entry_t *entry = Z_PTR_P(zv);
+
+ zval_ptr_dtor(&entry->key);
+ zval_ptr_dtor(&entry->value);
+ efree(entry);
+}
+
+ZEND_API void zend_async_context_tables_init(zend_async_context_t *context)
+{
+ zend_hash_init(&context->string_keys, 8, NULL, ZVAL_PTR_DTOR, false);
+ zend_hash_init(&context->object_keys, 8, NULL, async_context_entry_dtor, false);
+}
+
+ZEND_API void zend_async_context_tables_destroy(zend_async_context_t *context)
+{
+ zend_hash_destroy(&context->string_keys);
+ zend_hash_destroy(&context->object_keys);
+}
+
+ZEND_API zval *zend_async_context_entry_find(
+ zend_async_context_t *context, zend_string *skey, zend_object *okey)
+{
+ if (skey != NULL) {
+ return zend_hash_find(&context->string_keys, skey);
+ }
+
+ async_context_entry_t *entry = zend_hash_index_find_ptr(&context->object_keys, okey->handle);
+
+ return entry != NULL ? &entry->value : NULL;
+}
+
+ZEND_API void zend_async_context_entry_set(
+ zend_async_context_t *context, zend_string *skey, zend_object *okey, zval *value)
+{
+ if (skey != NULL) {
+ Z_TRY_ADDREF_P(value);
+ zend_hash_update(&context->string_keys, skey, value);
+ return;
+ }
+
+ async_context_entry_t *entry = zend_hash_index_find_ptr(&context->object_keys, okey->handle);
+
+ if (entry != NULL) {
+ /* The key object is already owned by the entry: only the value moves. */
+ zval old_value;
+ ZVAL_COPY_VALUE(&old_value, &entry->value);
+ ZVAL_COPY(&entry->value, value);
+ zval_ptr_dtor(&old_value);
+ return;
+ }
+
+ entry = emalloc(sizeof(*entry));
+ ZVAL_OBJ_COPY(&entry->key, okey);
+ ZVAL_COPY(&entry->value, value);
+ zend_hash_index_add_new_ptr(&context->object_keys, okey->handle, entry);
+}
+
+ZEND_API bool zend_async_context_entry_unset(
+ zend_async_context_t *context, zend_string *skey, zend_object *okey)
+{
+ if (skey != NULL) {
+ return zend_hash_del(&context->string_keys, skey) == SUCCESS;
+ }
+
+ return zend_hash_index_del(&context->object_keys, okey->handle) == SUCCESS;
+}
+
+ZEND_API void zend_async_context_entry_gc(zend_async_context_t *context, zend_get_gc_buffer *buf)
+{
+ zval *value;
+
+ ZEND_HASH_FOREACH_VAL(&context->string_keys, value)
+ {
+ zend_get_gc_buffer_add_zval(buf, value);
+ }
+ ZEND_HASH_FOREACH_END();
+
+ async_context_entry_t *entry;
+
+ ZEND_HASH_FOREACH_PTR(&context->object_keys, entry)
+ {
+ zend_get_gc_buffer_add_zval(buf, &entry->key);
+ zend_get_gc_buffer_add_zval(buf, &entry->value);
+ }
+ ZEND_HASH_FOREACH_END();
+}
+
+ZEND_API zend_object *zend_async_context_get(zend_coroutine_t *coroutine)
+{
+ if (coroutine == NULL) {
+ coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
+ }
+
+ /* No coroutine (concurrency off) or no class provider: no context. */
+ if (coroutine == NULL || zend_async_new_context_fn == NULL) {
+ return NULL;
+ }
+
+ if (coroutine->context == NULL) {
+ coroutine->context = zend_async_new_context_fn();
+ }
+
+ return coroutine->context;
+}
+
+/* Split a string-or-object key zval; any other type is a programming error
+ * of the C caller. */
+static bool async_context_key_split(zval *key, zend_string **skey, zend_object **okey)
+{
+ if (Z_TYPE_P(key) == IS_STRING) {
+ *skey = Z_STR_P(key);
+ return true;
+ }
+
+ if (Z_TYPE_P(key) == IS_OBJECT) {
+ *okey = Z_OBJ_P(key);
+ return true;
+ }
+
+ return false;
+}
+
+ZEND_API zval *zend_async_context_find(zend_coroutine_t *coroutine, zval *key)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ if (!async_context_key_split(key, &skey, &okey)) {
+ return NULL;
+ }
+
+ zend_object *store = zend_async_context_get(coroutine);
+
+ if (store == NULL) {
+ return NULL;
+ }
+
+ return zend_async_context_entry_find(ZEND_ASYNC_CONTEXT_FROM_OBJ(store), skey, okey);
+}
+
+ZEND_API bool zend_async_context_set(zend_coroutine_t *coroutine, zval *key, zval *value)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ if (!async_context_key_split(key, &skey, &okey)) {
+ return false;
+ }
+
+ zend_object *store = zend_async_context_get(coroutine);
+
+ if (store == NULL) {
+ return false;
+ }
+
+ zend_async_context_entry_set(ZEND_ASYNC_CONTEXT_FROM_OBJ(store), skey, okey, value);
+ return true;
+}
+
+ZEND_API bool zend_async_context_unset(zend_coroutine_t *coroutine, zval *key)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ if (!async_context_key_split(key, &skey, &okey)) {
+ return false;
+ }
+
+ zend_object *store = zend_async_context_get(coroutine);
+
+ if (store == NULL) {
+ return false;
+ }
+
+ return zend_async_context_entry_unset(ZEND_ASYNC_CONTEXT_FROM_OBJ(store), skey, okey);
+}
+
+ZEND_API void zend_async_context_destroy(zend_coroutine_t *coroutine)
+{
+ if (coroutine == NULL) {
+ coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
+
+ if (coroutine == NULL) {
+ return;
+ }
+ }
+
+ if (coroutine->context != NULL) {
+ OBJ_RELEASE(coroutine->context);
+ coroutine->context = NULL;
+ }
+}
+
+///////////////////////////////////////////////////////////////////
+/// Default slot implementations (no scheduler registered)
+///////////////////////////////////////////////////////////////////
+
+static ZEND_COLD void throw_no_scheduler(void)
+{
+ zend_throw_error(NULL, "The Async API requires a scheduler implementation to be registered");
+}
+
+/* Reachable without a scheduler: the engine and userland may hit these
+ * slots on a plain script. The rest of the table (new_coroutine) speaks
+ * about a live coroutine, which cannot exist while no scheduler is
+ * registered — those slots stay NULL, so a caller that reaches them is a
+ * bug and dies loudly instead of getting a polite exception. */
+
+static bool enqueue_coroutine_stub(
+ zend_coroutine_t *coroutine, zend_object *error, bool transfer_error)
+{
+ (void) coroutine;
+
+ if (error != NULL && transfer_error) {
+ OBJ_RELEASE(error);
+ }
+
+ throw_no_scheduler();
+ return false;
+}
+
+static bool suspend_stub(bool from_main, bool is_bailout)
+{
+ (void) from_main;
+ (void) is_bailout;
+ throw_no_scheduler();
+ return false;
+}
+
+static bool await_stub(zend_coroutine_t *coroutine)
+{
+ (void) coroutine;
+ throw_no_scheduler();
+ return false;
+}
+
+static bool cancel_stub(
+ zend_coroutine_t *coroutine, zend_object *error, bool transfer_error, const bool is_safely)
+{
+ (void) coroutine;
+ (void) is_safely;
+
+ if (error != NULL && transfer_error) {
+ OBJ_RELEASE(error);
+ }
+
+ zend_throw_error(NULL, "The Async scheduler does not support coroutine cancellation");
+ return false;
+}
+
+static zend_coroutine_t *launch_stub(void)
+{
+ return NULL;
+}
+
+static bool defer_stub(zend_async_microtask_t *task)
+{
+ (void) task;
+ throw_no_scheduler();
+ return false;
+}
+
+static bool shutdown_stub(void)
+{
+ return false;
+}
+
+static zend_class_entry *get_class_ce_default(zend_async_class type)
+{
+ /* Without a scheduler there are no Async classes; every exception type
+ * degrades to the base \Exception so error paths still work. */
+ if (type >= ZEND_ASYNC_EXCEPTION_DEFAULT) {
+ return zend_ce_exception;
+ }
+
+ return NULL;
+}
+
+static void default_call_on_main_stack(void (*fn)(void *), void *arg)
+{
+ fn(arg);
+}
+
+ZEND_API zend_async_new_coroutine_t zend_async_new_coroutine_fn = NULL;
+ZEND_API zend_async_gc_new_coroutine_t zend_async_gc_new_coroutine_fn = NULL;
+ZEND_API zend_async_enqueue_coroutine_t zend_async_enqueue_coroutine_fn = enqueue_coroutine_stub;
+ZEND_API zend_async_suspend_t zend_async_suspend_fn = suspend_stub;
+ZEND_API zend_async_cancel_t zend_async_cancel_fn = cancel_stub;
+ZEND_API zend_async_scheduler_launch_t zend_async_scheduler_launch_fn = launch_stub;
+ZEND_API zend_async_shutdown_t zend_async_shutdown_fn = shutdown_stub;
+ZEND_API zend_async_get_class_ce_t zend_async_get_class_ce_fn = get_class_ce_default;
+ZEND_API zend_async_call_on_main_stack_t zend_async_call_on_main_stack_fn =
+ default_call_on_main_stack;
+ZEND_API zend_async_defer_t zend_async_defer_fn = defer_stub;
+ZEND_API zend_async_coroutine_from_object_t zend_async_coroutine_from_object_fn = NULL;
+ZEND_API zend_async_intercept_fiber_t zend_async_intercept_fiber_fn = NULL;
+ZEND_API zend_async_coroutine_execute_data_t zend_async_coroutine_execute_data_fn = NULL;
+ZEND_API zend_async_coroutine_add_switch_handler_t zend_async_coroutine_add_switch_handler_fn = NULL;
+ZEND_API zend_async_coroutine_remove_switch_handler_t zend_async_coroutine_remove_switch_handler_fn = NULL;
+ZEND_API zend_async_coroutine_add_finish_handler_t zend_async_coroutine_add_finish_handler_fn = NULL;
+ZEND_API zend_async_coroutine_remove_finish_handler_t zend_async_coroutine_remove_finish_handler_fn = NULL;
+ZEND_API zend_async_coroutine_await_t zend_async_coroutine_await_fn = await_stub;
+ZEND_API zend_async_coroutine_add_awaiting_info_t zend_async_coroutine_add_awaiting_info_fn = NULL;
+ZEND_API zend_async_coroutine_remove_awaiting_info_t zend_async_coroutine_remove_awaiting_info_fn = NULL;
+ZEND_API zend_async_coroutine_get_awaiting_info_t zend_async_coroutine_get_awaiting_info_fn = NULL;
+
+///////////////////////////////////////////////////////////////////
+/// Registration
+///////////////////////////////////////////////////////////////////
+
+static const char *scheduler_module_name = NULL;
+
+/* True when the field lies within the size the provider was compiled against. */
+#define API_PROVIDES(api, field) \
+ ((api)->size >= offsetof(zend_async_scheduler_api_t, field) + sizeof((api)->field) \
+ && (api)->field != NULL)
+
+ZEND_API bool zend_async_scheduler_register(
+ const char *module, const zend_async_scheduler_api_t *api)
+{
+ if (api == NULL || module == NULL) {
+ return false;
+ }
+
+#ifdef ZTS
+ tsrm_mutex_lock(scheduler_mutex);
+#endif
+
+ /* A scheduler is registered once per process. */
+ if (scheduler_module_name != NULL) {
+ const char *owner = scheduler_module_name;
+
+#ifdef ZTS
+ tsrm_mutex_unlock(scheduler_mutex);
+#endif
+ zend_error(E_CORE_WARNING,
+ "The module %s cannot register an Async scheduler: %s already did",
+ module,
+ owner);
+ return false;
+ }
+
+ if (API_PROVIDES(api, new_coroutine)) {
+ zend_async_new_coroutine_fn = api->new_coroutine;
+ }
+
+ if (API_PROVIDES(api, gc_new_coroutine)) {
+ zend_async_gc_new_coroutine_fn = api->gc_new_coroutine;
+ }
+
+ if (API_PROVIDES(api, enqueue_coroutine)) {
+ zend_async_enqueue_coroutine_fn = api->enqueue_coroutine;
+ }
+
+ if (API_PROVIDES(api, suspend)) {
+ zend_async_suspend_fn = api->suspend;
+ }
+
+ if (API_PROVIDES(api, cancel)) {
+ zend_async_cancel_fn = api->cancel;
+ }
+
+ if (API_PROVIDES(api, launch)) {
+ zend_async_scheduler_launch_fn = api->launch;
+ }
+
+ if (API_PROVIDES(api, shutdown)) {
+ zend_async_shutdown_fn = api->shutdown;
+ }
+
+ if (API_PROVIDES(api, get_class_ce)) {
+ zend_async_get_class_ce_fn = api->get_class_ce;
+ }
+
+ if (API_PROVIDES(api, call_on_main_stack)) {
+ zend_async_call_on_main_stack_fn = api->call_on_main_stack;
+ }
+
+ if (API_PROVIDES(api, defer)) {
+ zend_async_defer_fn = api->defer;
+ }
+
+ if (API_PROVIDES(api, coroutine_from_object)) {
+ zend_async_coroutine_from_object_fn = api->coroutine_from_object;
+ }
+
+ if (API_PROVIDES(api, intercept_fiber)) {
+ zend_async_intercept_fiber_fn = api->intercept_fiber;
+ }
+
+ if (API_PROVIDES(api, coroutine_execute_data)) {
+ zend_async_coroutine_execute_data_fn = api->coroutine_execute_data;
+ }
+
+ if (API_PROVIDES(api, add_switch_handler)) {
+ zend_async_coroutine_add_switch_handler_fn = api->add_switch_handler;
+ }
+
+ if (API_PROVIDES(api, remove_switch_handler)) {
+ zend_async_coroutine_remove_switch_handler_fn = api->remove_switch_handler;
+ }
+
+ if (API_PROVIDES(api, add_finish_handler)) {
+ zend_async_coroutine_add_finish_handler_fn = api->add_finish_handler;
+ }
+
+ if (API_PROVIDES(api, remove_finish_handler)) {
+ zend_async_coroutine_remove_finish_handler_fn = api->remove_finish_handler;
+ }
+
+ if (API_PROVIDES(api, await)) {
+ zend_async_coroutine_await_fn = api->await;
+ }
+
+ if (API_PROVIDES(api, add_awaiting_info)) {
+ zend_async_coroutine_add_awaiting_info_fn = api->add_awaiting_info;
+ }
+
+ if (API_PROVIDES(api, remove_awaiting_info)) {
+ zend_async_coroutine_remove_awaiting_info_fn = api->remove_awaiting_info;
+ }
+
+ if (API_PROVIDES(api, get_awaiting_info)) {
+ zend_async_coroutine_get_awaiting_info_fn = api->get_awaiting_info;
+ }
+
+ /* The caller's string may be request-local: keep a process copy. */
+ scheduler_module_name = pestrdup(module, 1);
+
+#ifdef ZTS
+ tsrm_mutex_unlock(scheduler_mutex);
+#endif
+
+ ZEND_ASYNC_INITIALIZE;
+
+ return true;
+}
+
+/* Withdraw the registration and reset every slot to its default. The slots
+ * are process-wide: this runs at process shutdown, or when a just-registered
+ * scheduler fails to launch — never per request. The internal-context key
+ * registry is NOT touched here. */
+ZEND_API void zend_async_scheduler_unregister(void)
+{
+#ifdef ZTS
+ tsrm_mutex_lock(scheduler_mutex);
+#endif
+
+ if (scheduler_module_name != NULL) {
+ pefree((char *) scheduler_module_name, 1);
+ scheduler_module_name = NULL;
+ }
+
+ zend_async_new_coroutine_fn = NULL;
+ zend_async_gc_new_coroutine_fn = NULL;
+ zend_async_enqueue_coroutine_fn = enqueue_coroutine_stub;
+ zend_async_suspend_fn = suspend_stub;
+ zend_async_cancel_fn = cancel_stub;
+ zend_async_scheduler_launch_fn = launch_stub;
+ zend_async_shutdown_fn = shutdown_stub;
+ zend_async_get_class_ce_fn = get_class_ce_default;
+ zend_async_call_on_main_stack_fn = default_call_on_main_stack;
+ zend_async_defer_fn = defer_stub;
+ zend_async_coroutine_from_object_fn = NULL;
+ zend_async_intercept_fiber_fn = NULL;
+ zend_async_coroutine_execute_data_fn = NULL;
+ zend_async_coroutine_add_switch_handler_fn = NULL;
+ zend_async_coroutine_remove_switch_handler_fn = NULL;
+ zend_async_coroutine_add_finish_handler_fn = NULL;
+ zend_async_coroutine_remove_finish_handler_fn = NULL;
+ zend_async_coroutine_await_fn = await_stub;
+ zend_async_coroutine_add_awaiting_info_fn = NULL;
+ zend_async_coroutine_remove_awaiting_info_fn = NULL;
+ zend_async_coroutine_get_awaiting_info_fn = NULL;
+
+#ifdef ZTS
+ tsrm_mutex_unlock(scheduler_mutex);
+#endif
+}
+
+void zend_async_api_shutdown(void)
+{
+ zend_async_globals_dtor();
+ zend_async_scheduler_unregister();
+ internal_context_keys_shutdown();
+
+#ifdef ZTS
+ if (scheduler_mutex != NULL) {
+ tsrm_mutex_free(scheduler_mutex);
+ scheduler_mutex = NULL;
+ }
+#endif
+}
+
+ZEND_API bool zend_async_is_enabled(void)
+{
+ return scheduler_module_name != NULL;
+}
+
+ZEND_API const char *zend_async_get_scheduler_module(void)
+{
+ return scheduler_module_name;
+}
+
+ZEND_API zend_coroutine_t *zend_async_coroutine_from_object(zend_object *object)
+{
+ if (zend_async_coroutine_from_object_fn == NULL) {
+ return NULL;
+ }
+
+ return zend_async_coroutine_from_object_fn(object);
+}
+
+///////////////////////////////////////////////////////////////////
+/// Launch
+///////////////////////////////////////////////////////////////////
+
+ZEND_API bool zend_async_scheduler_launch(void)
+{
+ if (scheduler_module_name == NULL) {
+ return true;
+ }
+
+ zend_coroutine_t *main_coroutine = zend_async_scheduler_launch_fn();
+
+ if (main_coroutine == NULL) {
+ zend_throw_error(
+ NULL, "The scheduler failed to launch: no main coroutine for the top-level script");
+ return false;
+ }
+
+ /* The top-level script runs inside a coroutine from here on: the engine
+ * knows the main flow instead of discovering it at its first yield. */
+ ZEND_COROUTINE_SET_MAIN(main_coroutine);
+ ZEND_COROUTINE_SET_STATUS(main_coroutine, ZEND_COROUTINE_STATUS_RUNNING);
+ ZEND_ASYNC_MAIN_COROUTINE = main_coroutine;
+ ZEND_ASYNC_CURRENT_COROUTINE = main_coroutine;
+ ZEND_ASYNC_ACTIVATE;
+
+ return EG(exception) == NULL;
+}
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
new file mode 100644
index 000000000000..469ddf1f7fdd
--- /dev/null
+++ b/Zend/zend_async_API.h
@@ -0,0 +1,698 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Authors: Edmond |
+ +----------------------------------------------------------------------+
+*/
+#ifndef ZEND_ASYNC_API_H
+#define ZEND_ASYNC_API_H
+
+#include "zend_API.h"
+#include "zend_gc.h"
+
+/*
+ * Async Core ABI.
+ *
+ * This header is intentionally thin: it contains only the coroutine data
+ * structure and the function-pointer slots a scheduler implementation
+ * fills in. It contains NO policy: no scheduler, no reactor, no event
+ * system. How a coroutine waits — and for what — is entirely the
+ * provider's business; the core only offers the awaiting-info handler
+ * slots so that the wait state can be inspected for diagnostics.
+ *
+ * Execution belongs to the provider as well: fiber contexts, their pool
+ * and the entry point a coroutine starts on live in the scheduler, not
+ * here.
+ */
+
+typedef struct _zend_coroutine_s zend_coroutine_t;
+typedef struct _zend_fcall_s zend_fcall_t;
+typedef struct _zend_fiber zend_fiber;
+typedef void (*zend_coroutine_entry_t)(void);
+
+/* Class/exception registry keys resolved through zend_async_get_class_ce_fn. */
+typedef enum {
+ ZEND_ASYNC_CLASS_NO = 0,
+ ZEND_ASYNC_CLASS_COROUTINE = 1,
+
+ ZEND_ASYNC_EXCEPTION_DEFAULT = 30,
+ ZEND_ASYNC_EXCEPTION_CANCELLATION = 31,
+} zend_async_class;
+
+struct _zend_fcall_s {
+ zend_fcall_info fci;
+ zend_fcall_info_cache fci_cache;
+};
+
+///////////////////////////////////////////////////////////////////
+/// Coroutine
+///////////////////////////////////////////////////////////////////
+
+typedef void (*zend_coroutine_dispose_fn)(zend_coroutine_t *coroutine);
+
+/**
+ * Awaiting-info handler: describes ONE thing the coroutine is waiting for
+ * ("poll: socket 12, readable"). Registered by whoever suspends the
+ * coroutine, with `data` handed back verbatim. Registrations form a vector;
+ * the scheduler wipes it whole when the coroutine is enqueued — every
+ * description dies with the wait. Diagnostics only: no scheduling effect.
+ * The caller owns the returned string; NULL when there is nothing to say.
+ */
+typedef zend_string *(*zend_coroutine_awaiting_info_fn)(zend_coroutine_t *coroutine, void *data);
+
+/*
+ * Fired synchronously the moment the scheduler switches a coroutine out
+ * (is_enter=false) or back in (is_enter=true). Synchronous on purpose: a
+ * microtask runs only on the next tick, which this late in shutdown may never
+ * come, so the handler has to react at the switch itself. Return false to drop
+ * the handler after this call, true to keep it armed.
+ *
+ * Finishing is a separate mechanism (zend_coroutine_finish_handler_fn): one
+ * coroutine may have many awaiters, which doesn't fit a switch handler. Both
+ * lists live with the scheduler, not on zend_coroutine_t.
+ */
+typedef bool (*zend_coroutine_switch_handler_fn)(zend_coroutine_t *coroutine, bool is_enter);
+
+/* Fires exactly once, however the coroutine ends — return, exception,
+ * cancellation, bailout unwind. Clearing ->exception here marks it handled.
+ * waiter/data are stored at registration and handed back verbatim: waiter is
+ * the coroutine parked on this one (NULL when it hides behind data), data is
+ * the consumer's context. is_bailout=true: the scheduler is dying — clean own
+ * state only, do not schedule anything. */
+typedef bool (*zend_coroutine_finish_handler_fn)(
+ zend_coroutine_t *coroutine, zend_coroutine_t *waiter, void *data, bool is_bailout);
+
+/**
+ * Coroutine lifecycle. The single source of truth, managed by the
+ * scheduler.
+ */
+typedef enum {
+ ZEND_COROUTINE_STATUS_CREATED = 0, /* spawned, never executed */
+ ZEND_COROUTINE_STATUS_QUEUED, /* ready, waiting in the run queue */
+ ZEND_COROUTINE_STATUS_RUNNING, /* currently executing */
+ ZEND_COROUTINE_STATUS_SUSPENDED, /* waiting; see awaiting_info */
+ ZEND_COROUTINE_STATUS_FINISHED /* completed; result or exception is set */
+} zend_coroutine_status;
+
+struct _zend_coroutine_s {
+ /* Bits 0-3: zend_coroutine_status (the scheduler is the only writer);
+ * bits 4+: ZEND_COROUTINE_F_* modifiers. */
+ uint32_t flags;
+ /* Offset of the wrapping zend_object within the allocation, when the
+ * coroutine is embedded in one (single-allocation pattern: the object
+ * and the coroutine share one block, reached via container_of). 0 for
+ * a plain C coroutine with no PHP object. */
+ uint32_t object_offset;
+ /* Userland entry point. NULL for internal coroutines. */
+ zend_fcall_t *fcall;
+ /* C entry point. NULL for userland coroutines. */
+ zend_coroutine_entry_t internal_entry;
+ /* Custom data of the scheduler/extension. Nullable. */
+ void *extended_data;
+ /* Completion result. */
+ zval result;
+ /* Completion exception. Nullable. */
+ zend_object *exception;
+ /* Spawn location (diagnostics). */
+ zend_string *filename;
+ uint32_t lineno;
+ /* Extended dispose handler. Nullable. */
+ zend_coroutine_dispose_fn extended_dispose;
+ /* Coroutine-local storage; the provider initialises and destroys both at
+ * the coroutine's birth and death. */
+ HashTable internal_context; /* C extensions, numeric keys; PHP never sees it */
+ zend_object *context; /* the Async\Context object, lazy */
+};
+
+/* The lifecycle status is packed into the low 4 bits of `flags`. */
+#define ZEND_COROUTINE_STATUS_MASK 0xFu
+
+#define ZEND_COROUTINE_STATUS(coroutine) \
+ ((zend_coroutine_status) ((coroutine)->flags & ZEND_COROUTINE_STATUS_MASK))
+#define ZEND_COROUTINE_SET_STATUS(coroutine, _status) \
+ ((coroutine)->flags = \
+ ((coroutine)->flags & ~ZEND_COROUTINE_STATUS_MASK) | (uint32_t) (_status))
+
+/* Orthogonal modifiers packed above the status bits. */
+#define ZEND_COROUTINE_F_CANCELLED (1u << 4) /* cancellation was requested */
+#define ZEND_COROUTINE_F_MAIN (1u << 5) /* the main coroutine */
+#define ZEND_COROUTINE_F_FIBER (1u << 6) /* runs a fiber; extended_data -> zend_fiber */
+/* object_offset points at a stored zend_object* instead of an embedded
+ * object, for a provider whose coroutine and object live in separate
+ * allocations. */
+#define ZEND_COROUTINE_F_OBJ_REF (1u << 7)
+
+#define ZEND_COROUTINE_IS_CANCELLED(coroutine) \
+ (((coroutine)->flags & ZEND_COROUTINE_F_CANCELLED) != 0)
+#define ZEND_COROUTINE_SET_CANCELLED(coroutine) \
+ ((coroutine)->flags |= ZEND_COROUTINE_F_CANCELLED)
+
+#define ZEND_COROUTINE_IS_MAIN(coroutine) (((coroutine)->flags & ZEND_COROUTINE_F_MAIN) != 0)
+#define ZEND_COROUTINE_SET_MAIN(coroutine) ((coroutine)->flags |= ZEND_COROUTINE_F_MAIN)
+
+#define ZEND_COROUTINE_IS_FIBER(coroutine) (((coroutine)->flags & ZEND_COROUTINE_F_FIBER) != 0)
+#define ZEND_COROUTINE_SET_FIBER(coroutine) ((coroutine)->flags |= ZEND_COROUTINE_F_FIBER)
+
+/* The zend_object of a coroutine, or NULL for a plain C coroutine.
+ * Embedded model: the object lives at object_offset within the same
+ * allocation. OBJ_REF model: a zend_object* is stored at object_offset. */
+#define ZEND_COROUTINE_OBJECT(coroutine) \
+ ((coroutine)->object_offset == 0 \
+ ? NULL \
+ : ((coroutine)->flags & ZEND_COROUTINE_F_OBJ_REF) \
+ ? *(zend_object **) ((char *) (coroutine) + (coroutine)->object_offset) \
+ : (zend_object *) ((char *) (coroutine) + (coroutine)->object_offset))
+
+/* Shared ownership of a coroutine goes through its zend_object. */
+#define ZEND_COROUTINE_ADD_REF(coroutine) \
+ do { \
+ zend_object *_object = ZEND_COROUTINE_OBJECT(coroutine); \
+ ZEND_ASSERT(_object != NULL && "A coroutine is backed by a zend_object"); \
+ GC_ADDREF(_object); \
+ } while (0)
+
+#define ZEND_COROUTINE_RELEASE(coroutine) \
+ do { \
+ zend_object *_object = ZEND_COROUTINE_OBJECT(coroutine); \
+ ZEND_ASSERT(_object != NULL && "A coroutine is backed by a zend_object"); \
+ OBJ_RELEASE(_object); \
+ } while (0)
+
+/* Lifecycle predicates over the packed status. */
+#define ZEND_COROUTINE_IS_STARTED(coroutine) \
+ (ZEND_COROUTINE_STATUS(coroutine) != ZEND_COROUTINE_STATUS_CREATED)
+#define ZEND_COROUTINE_IS_QUEUED(coroutine) \
+ (ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_QUEUED)
+#define ZEND_COROUTINE_IS_RUNNING(coroutine) \
+ (ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_RUNNING)
+#define ZEND_COROUTINE_IS_SUSPENDED(coroutine) \
+ (ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_SUSPENDED)
+#define ZEND_COROUTINE_IS_FINISHED(coroutine) \
+ (ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_FINISHED)
+
+/**
+ * Build a zend_fcall_t from PHP function parameters
+ * (Z_PARAM_FUNC + Z_PARAM_VARIADIC_WITH_NAMED).
+ */
+#define ZEND_ASYNC_FCALL_DEFINE(_fcall_var, _src_fci, _src_fcc, _src_args, _src_args_count, _src_named_args) \
+ zend_fcall_t *_fcall_var = ecalloc(1, sizeof(zend_fcall_t)); \
+ _fcall_var->fci = _src_fci; \
+ _fcall_var->fci_cache = _src_fcc; \
+ if (_src_args_count) { \
+ _fcall_var->fci.param_count = _src_args_count; \
+ _fcall_var->fci.params = safe_emalloc(_src_args_count, sizeof(zval), 0); \
+ for (uint32_t _fcall_i = 0; _fcall_i < _src_args_count; _fcall_i++) { \
+ ZVAL_COPY(&_fcall_var->fci.params[_fcall_i], &_src_args[_fcall_i]); \
+ } \
+ } \
+ if (_src_named_args) { \
+ _fcall_var->fci.named_params = _src_named_args; \
+ GC_ADDREF(_src_named_args); \
+ } \
+ Z_TRY_ADDREF(_fcall_var->fci.function_name);
+
+/* The inverse of ZEND_ASYNC_FCALL_DEFINE: releases everything the macro
+ * copied and frees the zend_fcall_t. Safe on NULL. */
+#define ZEND_ASYNC_FCALL_FREE(_fcall_var) \
+ do { \
+ zend_fcall_t *_fcall = (_fcall_var); \
+ if (_fcall != NULL) { \
+ zend_fcall_info_args_clear(&_fcall->fci, true); \
+ if (_fcall->fci.named_params != NULL) { \
+ zend_array_release(_fcall->fci.named_params); \
+ } \
+ zval_ptr_dtor(&_fcall->fci.function_name); \
+ efree(_fcall); \
+ } \
+ } while (0)
+
+///////////////////////////////////////////////////////////////////
+/// Scheduler API slots
+///////////////////////////////////////////////////////////////////
+
+/* Allocate a coroutine in STATUS_CREATED; extra_size bytes are appended
+ * for the caller. */
+typedef zend_coroutine_t *(*zend_async_new_coroutine_t)(size_t extra_size);
+/* Allocate a coroutine for the engine's own GC bookkeeping (running
+ * zend_gc_collect_cycles() and its destructor phase). A separate slot lets a
+ * scheduler treat these specially — priority, concurrency limits — if it cares.
+ * NULL falls back to new_coroutine(0); see ZEND_ASYNC_GC_NEW_COROUTINE(). */
+typedef zend_coroutine_t *(*zend_async_gc_new_coroutine_t)(void);
+/* Put a CREATED/SUSPENDED coroutine into the run queue (-> STATUS_QUEUED).
+ * Enqueuing a fresh coroutine and resuming a suspended one are the same
+ * operation. A non-NULL `error` is thrown at the suspension point when the
+ * coroutine runs — how cancellation and IO/timeout failures reach waiting
+ * code; transfer_error passes ownership of the reference. */
+typedef bool (*zend_async_enqueue_coroutine_t)(
+ zend_coroutine_t *coroutine, zend_object *error, bool transfer_error);
+/* Yield the current coroutine (-> STATUS_SUSPENDED) and give control to the
+ * scheduler. Returns after somebody enqueues the coroutine; a delivered error
+ * is rethrown inside it, so the caller checks EG(exception) as usual.
+ * `from_main` = true is the after-main handoff: the main script (or its
+ * destructors) has finished and remaining coroutines get to run;
+ * `is_bailout` tells the scheduler the main flow ended with a bailout. */
+typedef bool (*zend_async_suspend_t)(bool from_main, bool is_bailout);
+/* Request cancellation: sets F_CANCELLED and wakes the coroutine with the
+ * error. `is_safely` defers delivery until a cancellation-safe point. */
+typedef bool (*zend_async_cancel_t)(
+ zend_coroutine_t *coroutine, zend_object *error, bool transfer_error, const bool is_safely);
+/* Start the scheduler and hand the engine the main coroutine: the top-level
+ * script is a coroutine from its first opcode, not a plain flow that becomes
+ * one at its first yield. The scheduler creates it (it is the scheduler's own
+ * object) and the engine records it as the main and the current coroutine.
+ * Returning NULL is a failure: without a main coroutine there is no flow to
+ * run the script in. */
+typedef zend_coroutine_t *(*zend_async_scheduler_launch_t)(void);
+typedef bool (*zend_async_shutdown_t)(void);
+typedef zend_class_entry *(*zend_async_get_class_ce_t)(zend_async_class type);
+/* Run fn(arg) on the main coroutine's OS-thread stack (FFI/JNI etc.). */
+typedef void (*zend_async_call_on_main_stack_t)(void (*fn)(void *), void *arg);
+
+/*
+ * Microtask: a one-shot task executed on the next scheduler tick.
+ *
+ * A structure with a lifetime: refcount ownership, cancellable at any
+ * moment. The consumer embeds it in its own container (container_of).
+ * The queue is OWNED BY THE PROVIDER; the core only routes the pointer
+ * through the defer slot. Provider tick contract:
+ *
+ * if (!ZEND_ASYNC_MICROTASK_IS_CANCELLED(task)) task->handler(task);
+ * ZEND_ASYNC_MICROTASK_RELEASE(task);
+ */
+typedef struct _zend_async_microtask_s zend_async_microtask_t;
+typedef void (*zend_microtask_handler_fn)(zend_async_microtask_t *task);
+
+struct _zend_async_microtask_s {
+ /* Runs on the tick; NOT invoked for a cancelled task. */
+ zend_microtask_handler_fn handler;
+ /* Releases the container's resources when the last reference dies.
+ * NULL means a plain efree of the task. */
+ zend_microtask_handler_fn dtor;
+ /* A full 32-bit reference counter. */
+ uint32_t ref_count;
+ /* 32 bits of named flags. */
+ uint32_t is_cancelled : 1;
+ uint32_t reserved : 31;
+};
+
+#define ZEND_ASYNC_MICROTASK_IS_CANCELLED(task) ((task)->is_cancelled != 0)
+#define ZEND_ASYNC_MICROTASK_CANCEL(task) ((task)->is_cancelled = 1)
+
+#define ZEND_ASYNC_MICROTASK_ADDREF(task) ((task)->ref_count++)
+
+#define ZEND_ASYNC_MICROTASK_RELEASE(task) \
+ do { \
+ if (--(task)->ref_count == 0) { \
+ if ((task)->dtor != NULL) { \
+ (task)->dtor(task); \
+ } \
+ efree(task); \
+ } \
+ } while (0)
+
+/* Queue the task on the provider's microtask queue. */
+typedef bool (*zend_async_defer_t)(zend_async_microtask_t *task);
+
+/* Park the current coroutine until `coroutine` finishes. A slot, not a series
+ * of calls, because awaiting is a scheduling decision: the provider owns the
+ * waiter bookkeeping (it lives on the awaited coroutine), may wake the waiter
+ * with a direct switch instead of the run queue, and marks the outcome as
+ * observed. False when the wait was aborted — a cancellation delivered to the
+ * waiter, or misuse (no current coroutine, awaiting itself). */
+typedef bool (*zend_async_coroutine_await_t)(zend_coroutine_t *coroutine);
+
+/*
+ * Resolve the provider's coroutine object to its coroutine.
+ *
+ * The coroutine object belongs to the scheduler (its class, its allocation
+ * model), so only the scheduler can walk that edge: the core keeps no
+ * registry of coroutines. Returns NULL when the object is not one of the
+ * scheduler's coroutines.
+ */
+typedef zend_coroutine_t *(*zend_async_coroutine_from_object_t)(zend_object *object);
+
+/*
+ * Where the engine offers a starting fiber to the scheduler.
+ *
+ * A fiber can be one of two things. A low-level one switches contexts by
+ * itself (that is how a userland event loop drives its own fibers) and the
+ * scheduler must not touch it. A high-level one is application code, and
+ * under a scheduler it is a coroutine like any other: it is queued, and its
+ * body runs on the context the scheduler hands it, never on a context of its
+ * own.
+ *
+ * Called on Fiber::start() while the API is active. The scheduler answers
+ * with a coroutine to run the fiber as, or with NULL to leave the fiber on
+ * the engine's legacy path. Only the scheduler can tell its own fibers from
+ * the application's, which is also what keeps it from adopting itself.
+ */
+typedef zend_coroutine_t *(*zend_async_intercept_fiber_t)(zend_fiber *fiber);
+
+/* The frame a suspended coroutine is parked in, or NULL when it is not
+ * suspended (or the provider does not track it). The stack a coroutine runs
+ * on belongs to the scheduler, so this is the only way for the engine to
+ * reach it — the garbage collector needs it to see the variables alive on
+ * that stack, and a backtrace needs it to walk past the coroutine. */
+typedef zend_execute_data *(*zend_async_coroutine_execute_data_t)(zend_coroutine_t *coroutine);
+
+/*
+ * Switch/finish handler storage lives with the scheduler (it already owns the
+ * coroutine's allocation); the core only adds and removes. Add returns a handle
+ * for remove(); 0 means the add failed (or the API is inactive), with nothing
+ * to remove.
+ */
+typedef uint32_t (*zend_async_coroutine_add_switch_handler_t)(
+ zend_coroutine_t *coroutine, zend_coroutine_switch_handler_fn handler);
+typedef bool (*zend_async_coroutine_remove_switch_handler_t)(
+ zend_coroutine_t *coroutine, uint32_t handler_id);
+typedef uint32_t (*zend_async_coroutine_add_finish_handler_t)(
+ zend_coroutine_t *coroutine, zend_coroutine_finish_handler_fn handler,
+ zend_coroutine_t *waiter, void *data);
+typedef bool (*zend_async_coroutine_remove_finish_handler_t)(
+ zend_coroutine_t *coroutine, uint32_t handler_id);
+
+/*
+ * Awaiting-info storage follows the same model: a vector owned by the
+ * scheduler, add/remove by handle (0 = the add did nothing). An explicit
+ * remove is rarely needed — enqueue wipes the vector whole; it is for a
+ * reason that disappears while the coroutine stays parked.
+ */
+typedef uint32_t (*zend_async_coroutine_add_awaiting_info_t)(
+ zend_coroutine_t *coroutine, zend_coroutine_awaiting_info_fn handler, void *data);
+typedef bool (*zend_async_coroutine_remove_awaiting_info_t)(
+ zend_coroutine_t *coroutine, uint32_t handler_id);
+/* Ask every registered handler and collect the non-NULL descriptions into a
+ * packed array of strings. NULL when the coroutine is not waiting for
+ * anything it can name. The caller owns the array. */
+typedef zend_array *(*zend_async_coroutine_get_awaiting_info_t)(zend_coroutine_t *coroutine);
+
+/**
+ * Scheduler API bundle. A provider fills the struct and calls
+ * zend_async_scheduler_register(). New slots are appended at the end only;
+ * `size` lets the core detect how much of the struct the provider knows.
+ * ABI compatibility rides on the standard PHP module API (ZEND_MODULE_API_NO),
+ * enforced when the provider extension is loaded — there is no separate
+ * Async API version.
+ */
+typedef struct _zend_async_scheduler_api_s {
+ size_t size; /* sizeof(zend_async_scheduler_api_t) at provider build time */
+
+ zend_async_new_coroutine_t new_coroutine;
+ zend_async_gc_new_coroutine_t gc_new_coroutine;
+ zend_async_enqueue_coroutine_t enqueue_coroutine;
+ zend_async_suspend_t suspend;
+ zend_async_cancel_t cancel;
+ zend_async_scheduler_launch_t launch;
+ zend_async_shutdown_t shutdown;
+ zend_async_get_class_ce_t get_class_ce;
+ zend_async_call_on_main_stack_t call_on_main_stack;
+ zend_async_defer_t defer;
+ zend_async_coroutine_from_object_t coroutine_from_object;
+ zend_async_intercept_fiber_t intercept_fiber;
+ zend_async_coroutine_execute_data_t coroutine_execute_data;
+ zend_async_coroutine_add_switch_handler_t add_switch_handler;
+ zend_async_coroutine_remove_switch_handler_t remove_switch_handler;
+ zend_async_coroutine_add_finish_handler_t add_finish_handler;
+ zend_async_coroutine_remove_finish_handler_t remove_finish_handler;
+ zend_async_coroutine_await_t await;
+ zend_async_coroutine_add_awaiting_info_t add_awaiting_info;
+ zend_async_coroutine_remove_awaiting_info_t remove_awaiting_info;
+ zend_async_coroutine_get_awaiting_info_t get_awaiting_info;
+} zend_async_scheduler_api_t;
+
+BEGIN_EXTERN_C()
+
+ZEND_API extern zend_async_new_coroutine_t zend_async_new_coroutine_fn;
+ZEND_API extern zend_async_gc_new_coroutine_t zend_async_gc_new_coroutine_fn;
+ZEND_API extern zend_async_enqueue_coroutine_t zend_async_enqueue_coroutine_fn;
+ZEND_API extern zend_async_suspend_t zend_async_suspend_fn;
+ZEND_API extern zend_async_cancel_t zend_async_cancel_fn;
+ZEND_API extern zend_async_scheduler_launch_t zend_async_scheduler_launch_fn;
+ZEND_API extern zend_async_shutdown_t zend_async_shutdown_fn;
+ZEND_API extern zend_async_get_class_ce_t zend_async_get_class_ce_fn;
+ZEND_API extern zend_async_call_on_main_stack_t zend_async_call_on_main_stack_fn;
+ZEND_API extern zend_async_defer_t zend_async_defer_fn;
+ZEND_API extern zend_async_coroutine_from_object_t zend_async_coroutine_from_object_fn;
+ZEND_API extern zend_async_intercept_fiber_t zend_async_intercept_fiber_fn;
+ZEND_API extern zend_async_coroutine_execute_data_t zend_async_coroutine_execute_data_fn;
+ZEND_API extern zend_async_coroutine_add_switch_handler_t zend_async_coroutine_add_switch_handler_fn;
+ZEND_API extern zend_async_coroutine_remove_switch_handler_t zend_async_coroutine_remove_switch_handler_fn;
+ZEND_API extern zend_async_coroutine_add_finish_handler_t zend_async_coroutine_add_finish_handler_fn;
+ZEND_API extern zend_async_coroutine_remove_finish_handler_t zend_async_coroutine_remove_finish_handler_fn;
+ZEND_API extern zend_async_coroutine_await_t zend_async_coroutine_await_fn;
+ZEND_API extern zend_async_coroutine_add_awaiting_info_t zend_async_coroutine_add_awaiting_info_fn;
+ZEND_API extern zend_async_coroutine_remove_awaiting_info_t zend_async_coroutine_remove_awaiting_info_fn;
+ZEND_API extern zend_async_coroutine_get_awaiting_info_t zend_async_coroutine_get_awaiting_info_fn;
+
+/* Resolve a coroutine object to its coroutine through the provider's slot.
+ * NULL when no scheduler is registered, when it provides no resolver, or
+ * when the object is not one of its coroutines. */
+ZEND_API zend_coroutine_t *zend_async_coroutine_from_object(zend_object *object);
+
+/* Launch the scheduler: calls the launch slot, marks the coroutine it returns
+ * as the main one and records it as current. False when no scheduler is
+ * registered or it failed to produce a main coroutine. */
+ZEND_API bool zend_async_scheduler_launch(void);
+
+ZEND_API bool zend_async_scheduler_register(
+ const char *module, const zend_async_scheduler_api_t *api);
+/* Withdraw the registration and reset every slot to its default. */
+ZEND_API void zend_async_scheduler_unregister(void);
+
+ZEND_API bool zend_async_is_enabled(void);
+/* The module name of the registered scheduler, or NULL when none. */
+ZEND_API const char *zend_async_get_scheduler_module(void);
+
+/* The internal (C-extension) context: engine-owned per-coroutine storage
+ * under process-unique numeric keys, allocated once per process from a
+ * static C-string name (typically at MINIT). NULL coroutine = the current
+ * one. Values may hold raw C data, so the store is structurally
+ * unreachable from PHP. */
+ZEND_API uint32_t zend_async_internal_context_key_alloc(const char *key_name);
+ZEND_API zval *zend_async_internal_context_find(zend_coroutine_t *coroutine, uint32_t key);
+ZEND_API bool zend_async_internal_context_set(
+ zend_coroutine_t *coroutine, uint32_t key, zval *value);
+ZEND_API bool zend_async_internal_context_unset(zend_coroutine_t *coroutine, uint32_t key);
+/* The embedded table's birth and death: the provider calls init when it
+ * creates the coroutine and destroy when the coroutine dies. */
+ZEND_API void zend_async_internal_context_init(zend_coroutine_t *coroutine);
+ZEND_API void zend_async_internal_context_destroy(zend_coroutine_t *coroutine);
+
+/*
+ * The userland context: engine storage with no engine class. The core owns
+ * the layout and the operations; a provider extension registers the PHP
+ * class (Async\Context) and hands the factory through
+ * zend_async_new_context_fn. An extension may wrap the struct with its own
+ * fields in front — std sits at a fixed offset, so an object always maps
+ * back to the base with ZEND_ASYNC_CONTEXT_FROM_OBJ.
+ *
+ * String keys live in string_keys; object keys in object_keys, indexed by
+ * handle, where the entry owns the key object as well as the value —
+ * handles are reused once an object dies, and a stored key that outlived
+ * its object would alias whatever takes its handle next.
+ */
+typedef struct {
+ HashTable string_keys;
+ HashTable object_keys;
+ zend_object std;
+} zend_async_context_t;
+
+#define ZEND_ASYNC_CONTEXT_FROM_OBJ(object) \
+ ((zend_async_context_t *) ((char *) (object) - offsetof(zend_async_context_t, std)))
+
+/* Mints a context instance (the provider's class). NULL while no provider
+ * registered a class: the userland context is then unavailable. */
+typedef zend_object *(*zend_async_new_context_t)(void);
+ZEND_API extern zend_async_new_context_t zend_async_new_context_fn;
+
+/* The struct-level operations: the provider's class methods and free/GC
+ * handlers are thin wrappers over these. Keys are a zend_string OR a
+ * zend_object, exactly one non-NULL. */
+ZEND_API void zend_async_context_tables_init(zend_async_context_t *context);
+ZEND_API void zend_async_context_tables_destroy(zend_async_context_t *context);
+ZEND_API zval *zend_async_context_entry_find(
+ zend_async_context_t *context, zend_string *skey, zend_object *okey);
+ZEND_API void zend_async_context_entry_set(
+ zend_async_context_t *context, zend_string *skey, zend_object *okey, zval *value);
+ZEND_API bool zend_async_context_entry_unset(
+ zend_async_context_t *context, zend_string *skey, zend_object *okey);
+ZEND_API void zend_async_context_entry_gc(zend_async_context_t *context, zend_get_gc_buffer *buf);
+
+/* The coroutine-level view (NULL coroutine = the current one). NULL when
+ * there is no coroutine (concurrency off) or no class provider. Keys here
+ * are string-or-object zvals; any other type is the C caller's bug. */
+ZEND_API zend_object *zend_async_context_get(zend_coroutine_t *coroutine);
+ZEND_API zval *zend_async_context_find(zend_coroutine_t *coroutine, zval *key);
+ZEND_API bool zend_async_context_set(zend_coroutine_t *coroutine, zval *key, zval *value);
+ZEND_API bool zend_async_context_unset(zend_coroutine_t *coroutine, zval *key);
+ZEND_API void zend_async_context_destroy(zend_coroutine_t *coroutine);
+
+END_EXTERN_C()
+
+/* The userland context (NULL coroutine = current, main included). */
+#define ZEND_ASYNC_CONTEXT_GET(coroutine) zend_async_context_get(coroutine)
+#define ZEND_ASYNC_CONTEXT_FIND(coroutine, key) zend_async_context_find((coroutine), (key))
+#define ZEND_ASYNC_CONTEXT_SET(coroutine, key, value) \
+ zend_async_context_set((coroutine), (key), (value))
+#define ZEND_ASYNC_CONTEXT_UNSET(coroutine, key) zend_async_context_unset((coroutine), (key))
+#define ZEND_ASYNC_CONTEXT_DESTROY(coroutine) zend_async_context_destroy(coroutine)
+
+/* The internal context (NULL coroutine = current). */
+#define ZEND_ASYNC_INTERNAL_CONTEXT_KEY_ALLOC(name) zend_async_internal_context_key_alloc(name)
+#define ZEND_ASYNC_INTERNAL_CONTEXT_FIND(coroutine, key) \
+ zend_async_internal_context_find((coroutine), (key))
+#define ZEND_ASYNC_INTERNAL_CONTEXT_SET(coroutine, key, value) \
+ zend_async_internal_context_set((coroutine), (key), (value))
+#define ZEND_ASYNC_INTERNAL_CONTEXT_UNSET(coroutine, key) \
+ zend_async_internal_context_unset((coroutine), (key))
+#define ZEND_ASYNC_INTERNAL_CONTEXT_DESTROY(coroutine) \
+ zend_async_internal_context_destroy(coroutine)
+
+#define ZEND_ASYNC_NEW_COROUTINE() zend_async_new_coroutine_fn(0)
+#define ZEND_ASYNC_NEW_COROUTINE_EX(extra_size) zend_async_new_coroutine_fn(extra_size)
+/* A coroutine for the engine's own GC bookkeeping. Falls back to the plain
+ * new_coroutine slot when the scheduler does not distinguish them; NULL when
+ * the provider cannot mint C coroutines at all. */
+#define ZEND_ASYNC_GC_NEW_COROUTINE() \
+ (zend_async_gc_new_coroutine_fn != NULL \
+ ? zend_async_gc_new_coroutine_fn() \
+ : zend_async_new_coroutine_fn != NULL ? zend_async_new_coroutine_fn(0) : NULL)
+#define ZEND_ASYNC_ENQUEUE_COROUTINE(coroutine) \
+ zend_async_enqueue_coroutine_fn((coroutine), NULL, false)
+/* Enqueue-with-error: the resume/cancellation delivery channel. */
+#define ZEND_ASYNC_ENQUEUE_WITH_ERROR(coroutine, error, transfer_error) \
+ zend_async_enqueue_coroutine_fn((coroutine), (error), (transfer_error))
+#define ZEND_ASYNC_SUSPEND() zend_async_suspend_fn(false, false)
+/* Park the current coroutine until `coroutine` finishes. */
+#define ZEND_ASYNC_AWAIT(coroutine) zend_async_coroutine_await_fn(coroutine)
+/* Hand control to the scheduler one last time after the main flow ends.
+ * Safe to call unconditionally: a no-op while the Async API is inactive. */
+#define ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(is_bailout) \
+ do { \
+ if (ZEND_ASYNC_IS_ACTIVE) { \
+ zend_async_suspend_fn(true, (is_bailout)); \
+ } \
+ } while (0)
+#define ZEND_ASYNC_CANCEL(coroutine, error, transfer_error) \
+ zend_async_cancel_fn((coroutine), (error), (transfer_error), false)
+/* Starts the scheduler and installs the main coroutine it returns. */
+#define ZEND_ASYNC_SCHEDULER_LAUNCH() zend_async_scheduler_launch()
+#define ZEND_ASYNC_SHUTDOWN() zend_async_shutdown_fn()
+#define ZEND_ASYNC_GET_CE(type) zend_async_get_class_ce_fn(type)
+#define ZEND_ASYNC_GET_EXCEPTION_CE(type) zend_async_get_class_ce_fn(type)
+#define ZEND_ASYNC_CALL_ON_MAIN_STACK(fn, arg) zend_async_call_on_main_stack_fn((fn), (arg))
+#define ZEND_ASYNC_DEFER(task) zend_async_defer_fn(task)
+
+/* The frame a suspended coroutine is parked in, or NULL. */
+#define ZEND_ASYNC_COROUTINE_EXECUTE_DATA(coroutine) \
+ (zend_async_coroutine_execute_data_fn != NULL \
+ ? zend_async_coroutine_execute_data_fn(coroutine) \
+ : NULL)
+
+/* The coroutine to run a starting fiber as, or NULL for the legacy path. A
+ * scheduler with no intercept_fiber slot leaves every fiber alone. */
+#define ZEND_ASYNC_INTERCEPT_FIBER(fiber) \
+ ((ZEND_ASYNC_IS_ACTIVE && zend_async_intercept_fiber_fn != NULL) \
+ ? zend_async_intercept_fiber_fn(fiber) \
+ : NULL)
+
+/* Watch `coroutine` switching in/out (ZEND_ASYNC_ADD_SWITCH_HANDLER) or
+ * finishing (ZEND_ASYNC_ADD_FINISH_HANDLER). The scheduler owns the list;
+ * these only add/remove by handle. 0 means the add did nothing (no
+ * scheduler, or the provider does not support it) — there is nothing to
+ * remove in that case. */
+#define ZEND_ASYNC_ADD_SWITCH_HANDLER(coroutine, handler) \
+ (zend_async_coroutine_add_switch_handler_fn != NULL \
+ ? zend_async_coroutine_add_switch_handler_fn((coroutine), (handler)) \
+ : 0)
+#define ZEND_ASYNC_REMOVE_SWITCH_HANDLER(coroutine, handler_id) \
+ ((void) ((handler_id) != 0 && zend_async_coroutine_remove_switch_handler_fn != NULL \
+ && zend_async_coroutine_remove_switch_handler_fn((coroutine), (handler_id))))
+#define ZEND_ASYNC_ADD_FINISH_HANDLER(coroutine, handler, waiter, data) \
+ (zend_async_coroutine_add_finish_handler_fn != NULL \
+ ? zend_async_coroutine_add_finish_handler_fn((coroutine), (handler), (waiter), (data)) \
+ : 0)
+#define ZEND_ASYNC_REMOVE_FINISH_HANDLER(coroutine, handler_id) \
+ ((void) ((handler_id) != 0 && zend_async_coroutine_remove_finish_handler_fn != NULL \
+ && zend_async_coroutine_remove_finish_handler_fn((coroutine), (handler_id))))
+
+/* Describe what `coroutine` waits for (ZEND_ASYNC_ADD_AWAITING_INFO) and
+ * fetch the collected descriptions (ZEND_ASYNC_GET_AWAITING_INFO — a packed
+ * array of strings owned by the caller, or NULL). The scheduler clears the
+ * registrations itself when the coroutine is enqueued. */
+#define ZEND_ASYNC_ADD_AWAITING_INFO(coroutine, handler, data) \
+ (zend_async_coroutine_add_awaiting_info_fn != NULL \
+ ? zend_async_coroutine_add_awaiting_info_fn((coroutine), (handler), (data)) \
+ : 0)
+#define ZEND_ASYNC_REMOVE_AWAITING_INFO(coroutine, handler_id) \
+ ((void) ((handler_id) != 0 && zend_async_coroutine_remove_awaiting_info_fn != NULL \
+ && zend_async_coroutine_remove_awaiting_info_fn((coroutine), (handler_id))))
+#define ZEND_ASYNC_GET_AWAITING_INFO(coroutine) \
+ (zend_async_coroutine_get_awaiting_info_fn != NULL \
+ ? zend_async_coroutine_get_awaiting_info_fn(coroutine) \
+ : NULL)
+
+///////////////////////////////////////////////////////////////////
+/// Globals
+///////////////////////////////////////////////////////////////////
+
+typedef enum {
+ ZEND_ASYNC_OFF,
+ ZEND_ASYNC_READY,
+ ZEND_ASYNC_ACTIVE
+} zend_async_state_t;
+
+typedef struct {
+ zend_async_state_t state;
+ /* Currently executing coroutine. NULL outside coroutine context.
+ * Written by the scheduler before every switch: this slot, not the
+ * fiber context, is what tells a starting coroutine who it is. */
+ zend_coroutine_t *coroutine;
+ /* The main coroutine (top-level script on the OS thread stack). */
+ zend_coroutine_t *main_coroutine;
+ /* Number of live (not finished) coroutines. */
+ unsigned int active_coroutine_count;
+ /* True while the scheduler's own machinery runs. */
+ bool in_scheduler_context;
+ /* Uncaught exception carried out of the shutdown drain. */
+ zend_object *exit_exception;
+} zend_async_globals_t;
+
+BEGIN_EXTERN_C()
+#ifdef ZTS
+ZEND_API extern int zend_async_globals_id;
+ZEND_API extern size_t zend_async_globals_offset;
+#define ZEND_ASYNC_G(v) ZEND_TSRMG_FAST(zend_async_globals_offset, zend_async_globals_t *, v)
+#else
+ZEND_API extern zend_async_globals_t zend_async_globals_api;
+#define ZEND_ASYNC_G(v) (zend_async_globals_api.v)
+#endif
+
+void zend_async_globals_ctor(void);
+void zend_async_globals_dtor(void);
+void zend_async_api_shutdown(void);
+
+END_EXTERN_C()
+
+#define ZEND_ASYNC_ON (ZEND_ASYNC_G(state) > ZEND_ASYNC_OFF)
+#define ZEND_ASYNC_IS_ACTIVE (ZEND_ASYNC_G(state) == ZEND_ASYNC_ACTIVE)
+#define ZEND_ASYNC_IS_OFF (ZEND_ASYNC_G(state) == ZEND_ASYNC_OFF)
+#define ZEND_ASYNC_IS_READY (ZEND_ASYNC_G(state) == ZEND_ASYNC_READY)
+#define ZEND_ASYNC_ACTIVATE ZEND_ASYNC_G(state) = ZEND_ASYNC_ACTIVE
+#define ZEND_ASYNC_INITIALIZE ZEND_ASYNC_G(state) = ZEND_ASYNC_READY
+#define ZEND_ASYNC_DEACTIVATE ZEND_ASYNC_G(state) = ZEND_ASYNC_OFF
+
+#define ZEND_ASYNC_CURRENT_COROUTINE ZEND_ASYNC_G(coroutine)
+#define ZEND_ASYNC_MAIN_COROUTINE ZEND_ASYNC_G(main_coroutine)
+#define ZEND_ASYNC_EXIT_EXCEPTION ZEND_ASYNC_G(exit_exception)
+#define ZEND_ASYNC_ACTIVE_COROUTINE_COUNT ZEND_ASYNC_G(active_coroutine_count)
+#define ZEND_ASYNC_IN_SCHEDULER_CONTEXT ZEND_ASYNC_G(in_scheduler_context)
+
+#endif /* ZEND_ASYNC_API_H */
diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c
index 88b0a485750a..a8d5500aca47 100644
--- a/Zend/zend_execute_API.c
+++ b/Zend/zend_execute_API.c
@@ -33,6 +33,7 @@
#include "zend_vm.h"
#include "zend_float.h"
#include "zend_fibers.h"
+#include "zend_async_API.h"
#include "zend_weakrefs.h"
#include "zend_inheritance.h"
#include "zend_observer.h"
@@ -180,6 +181,8 @@ void init_executor(void) /* {{{ */
EG(fake_scope) = NULL;
EG(trampoline).common.function_name = NULL;
+ memset(&EG(shutdown_context), 0, sizeof(EG(shutdown_context)));
+
EG(ht_iterators_count) = sizeof(EG(ht_iterators_slots)) / sizeof(HashTableIterator);
EG(ht_iterators_used) = 0;
EG(ht_iterators) = EG(ht_iterators_slots);
@@ -208,19 +211,6 @@ void init_executor(void) /* {{{ */
}
/* }}} */
-static int zval_call_destructor(zval *zv) /* {{{ */
-{
- if (Z_TYPE_P(zv) == IS_INDIRECT) {
- zv = Z_INDIRECT_P(zv);
- }
- if (Z_TYPE_P(zv) == IS_OBJECT && Z_REFCOUNT_P(zv) == 1) {
- return ZEND_HASH_APPLY_REMOVE;
- } else {
- return ZEND_HASH_APPLY_KEEP;
- }
-}
-/* }}} */
-
static void zend_unclean_zval_ptr_dtor(zval *zv) /* {{{ */
{
if (Z_TYPE_P(zv) == IS_INDIRECT) {
@@ -249,21 +239,120 @@ static ZEND_COLD void zend_throw_or_error(uint32_t fetch_type, zend_class_entry
}
/* }}} */
+static void shutdown_destructors_iterator_entry(void)
+{
+ shutdown_destructors();
+}
+
+/* Torn down mid-pass: reset the cursor and give up on the remaining
+ * destructors — dispose may run inside a bailout, no user code from here. */
+static void shutdown_destructors_coroutine_dtor(zend_coroutine_t *coroutine)
+{
+ if (EG(shutdown_context).coroutine == coroutine) {
+ EG(shutdown_context).coroutine = NULL;
+ EG(shutdown_context).pass = ZEND_SHUTDOWN_PASS_NONE;
+ zend_error(E_WARNING, "Shutdown destructors coroutine was not finished properly");
+ EG(symbol_table).pDestructor = zend_unclean_zval_ptr_dtor;
+ zend_objects_store_mark_destructed(&EG(objects_store));
+ }
+}
+
+static bool shutdown_destructors_switch_handler(zend_coroutine_t *coroutine, bool is_enter)
+{
+ (void) coroutine;
+
+ if (is_enter) {
+ return true;
+ }
+
+ if (EG(shutdown_context).pass != ZEND_SHUTDOWN_PASS_SYMBOLS) {
+ return false;
+ }
+
+ zend_coroutine_t *iterator = ZEND_ASYNC_GC_NEW_COROUTINE();
+
+ if (UNEXPECTED(iterator == NULL)) {
+ return false;
+ }
+
+ iterator->internal_entry = shutdown_destructors_iterator_entry;
+ iterator->extended_dispose = shutdown_destructors_coroutine_dtor;
+
+ ZEND_ASYNC_ENQUEUE_COROUTINE(iterator);
+
+ return false;
+}
+
void shutdown_destructors(void) /* {{{ */
{
+ zend_coroutine_t *coroutine = ZEND_ASYNC_IS_ACTIVE ? ZEND_ASYNC_CURRENT_COROUTINE : NULL;
+
+ if (coroutine != NULL) {
+ ZEND_ASYNC_ADD_SWITCH_HANDLER(coroutine, shutdown_destructors_switch_handler);
+ }
+
if (CG(unclean_shutdown)) {
EG(symbol_table).pDestructor = zend_unclean_zval_ptr_dtor;
}
+
+ HashTable *symbol_table = &EG(symbol_table);
+
+ if (EG(shutdown_context).pass != ZEND_SHUTDOWN_PASS_SYMBOLS) {
+ EG(shutdown_context).pass = ZEND_SHUTDOWN_PASS_SYMBOLS;
+ EG(shutdown_context).coroutine = coroutine;
+ EG(shutdown_context).num_elements = zend_hash_num_elements(symbol_table);
+ EG(shutdown_context).idx = symbol_table->nNumUsed;
+ }
+
zend_try {
- uint32_t symbols;
+ bool should_continue = false;
+
do {
- symbols = zend_hash_num_elements(&EG(symbol_table));
- zend_hash_reverse_apply(&EG(symbol_table), (apply_func_t) zval_call_destructor);
- } while (symbols != zend_hash_num_elements(&EG(symbol_table)));
- zend_objects_store_call_destructors(&EG(objects_store));
+ if (should_continue) {
+ EG(shutdown_context).num_elements = zend_hash_num_elements(symbol_table);
+ EG(shutdown_context).idx = symbol_table->nNumUsed;
+ } else {
+ should_continue = true;
+ }
+
+ while (EG(shutdown_context).idx > 0) {
+ EG(shutdown_context).idx--;
+
+ Bucket *p = symbol_table->arData + EG(shutdown_context).idx;
+
+ if (UNEXPECTED(Z_TYPE(p->val) == IS_UNDEF)) {
+ continue;
+ }
+
+ const zval *zv = &p->val;
+ if (Z_TYPE_P(zv) == IS_INDIRECT) {
+ zv = Z_INDIRECT_P(zv);
+ }
+
+ if (Z_TYPE_P(zv) == IS_OBJECT && Z_REFCOUNT_P(zv) == 1) {
+ zend_hash_del_bucket(symbol_table, p);
+ }
+
+ if (coroutine != NULL && coroutine != ZEND_ASYNC_CURRENT_COROUTINE) {
+ should_continue = false;
+ break;
+ }
+ }
+
+ if (!should_continue) {
+ break;
+ }
+ } while (EG(shutdown_context).num_elements != zend_hash_num_elements(symbol_table));
+
+ if (should_continue) {
+ EG(shutdown_context).pass = ZEND_SHUTDOWN_PASS_NONE;
+ EG(shutdown_context).coroutine = NULL;
+ zend_objects_store_call_destructors_async(&EG(objects_store));
+ }
} zend_catch {
/* if we couldn't destruct cleanly, mark all objects as destructed anyway */
zend_objects_store_mark_destructed(&EG(objects_store));
+ EG(shutdown_context).pass = ZEND_SHUTDOWN_PASS_NONE;
} zend_end_try();
}
/* }}} */
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index c91436050856..4d93457aff04 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -550,14 +550,61 @@ ZEND_API void zend_fiber_switch_context(zend_fiber_transfer *transfer)
}
}
-static void zend_fiber_cleanup(zend_fiber_context *context)
+ZEND_API zend_execute_data *zend_fiber_vm_stack_start(
+ zend_fiber_context *context, zend_function *root_function)
{
- zend_fiber *fiber = zend_fiber_from_context(context);
+ /* Determine the current error_reporting ini setting. NULL or an empty
+ * string means "never configured"; only an explicit "0" keeps 0. */
+ zend_long error_reporting = zend_ini_long_literal("error_reporting");
+ if (!error_reporting) {
+ zend_string *value = zend_ini_str_literal("error_reporting");
+
+ if (value == NULL || ZSTR_LEN(value) == 0) {
+ error_reporting = E_ALL;
+ }
+ }
+
+ zend_vm_stack stack = zend_vm_stack_new_page(ZEND_FIBER_VM_STACK_SIZE, NULL);
+
+ EG(vm_stack) = stack;
+ EG(vm_stack_top) = stack->top + ZEND_CALL_FRAME_SLOT;
+ EG(vm_stack_end) = stack->end;
+ EG(vm_stack_page_size) = ZEND_FIBER_VM_STACK_SIZE;
+ zend_execute_data *execute_data = (zend_execute_data *) stack->top;
+
+ memset(execute_data, 0, sizeof(zend_execute_data));
+ execute_data->func = root_function;
+ execute_data->prev_execute_data = EG(current_execute_data);
+
+ EG(current_execute_data) = execute_data;
+ EG(jit_trace_num) = 0;
+ EG(error_reporting) = (int) error_reporting;
+
+#ifdef ZEND_CHECK_STACK_LIMIT
+ EG(stack_base) = zend_fiber_stack_base(context->stack);
+ EG(stack_limit) = zend_fiber_stack_limit(context->stack);
+#else
+ (void) context;
+#endif
+
+ return execute_data;
+}
+
+ZEND_API void zend_fiber_vm_stack_free(zend_vm_stack stack)
+{
zend_vm_stack current_stack = EG(vm_stack);
- EG(vm_stack) = fiber->vm_stack;
+
+ EG(vm_stack) = stack;
zend_vm_stack_destroy();
EG(vm_stack) = current_stack;
+}
+
+static void zend_fiber_cleanup(zend_fiber_context *context)
+{
+ zend_fiber *fiber = zend_fiber_from_context(context);
+
+ zend_fiber_vm_stack_free(fiber->vm_stack);
fiber->execute_data = NULL;
fiber->stack_bottom = NULL;
fiber->caller = NULL;
@@ -570,39 +617,12 @@ static ZEND_STACK_ALIGNED void zend_fiber_execute(zend_fiber_transfer *transfer)
zend_fiber *fiber = EG(active_fiber);
- /* Determine the current error_reporting ini setting. */
- zend_long error_reporting = zend_ini_long_literal("error_reporting");
- /* If error_reporting is 0 and not explicitly set to 0, zend_ini_str returns a null pointer. */
- if (!error_reporting && !zend_ini_str_literal("error_reporting")) {
- error_reporting = E_ALL;
- }
-
EG(vm_stack) = NULL;
zend_first_try {
- zend_vm_stack stack = zend_vm_stack_new_page(ZEND_FIBER_VM_STACK_SIZE, NULL);
- EG(vm_stack) = stack;
- EG(vm_stack_top) = stack->top + ZEND_CALL_FRAME_SLOT;
- EG(vm_stack_end) = stack->end;
- EG(vm_stack_page_size) = ZEND_FIBER_VM_STACK_SIZE;
-
- fiber->execute_data = (zend_execute_data *) stack->top;
+ fiber->execute_data = zend_fiber_vm_stack_start(&fiber->context, &zend_fiber_function);
fiber->stack_bottom = fiber->execute_data;
- memset(fiber->execute_data, 0, sizeof(zend_execute_data));
-
- fiber->execute_data->func = &zend_fiber_function;
- fiber->stack_bottom->prev_execute_data = EG(current_execute_data);
-
- EG(current_execute_data) = fiber->execute_data;
- EG(jit_trace_num) = 0;
- EG(error_reporting) = error_reporting;
-
-#ifdef ZEND_CHECK_STACK_LIMIT
- EG(stack_base) = zend_fiber_stack_base(fiber->context.stack);
- EG(stack_limit) = zend_fiber_stack_limit(fiber->context.stack);
-#endif
-
fiber->fci.retval = &fiber->result;
zend_call_function(&fiber->fci, &fiber->fci_cache);
@@ -708,6 +728,316 @@ static zend_always_inline zend_fiber_transfer zend_fiber_suspend_internal(zend_f
return zend_fiber_switch_to(caller, value, false);
}
+/////////////////////////////////////////////////////////////////////
+/// Coroutine mode: the fiber as a coroutine of the scheduler
+/////////////////////////////////////////////////////////////////////
+
+/* A fiber under a scheduler has no context of its own: the scheduler runs its
+ * body through this entry point. start()/resume()/throw() wake the coroutine
+ * and wait; Fiber::suspend() wakes the waiter and parks. */
+static void zend_fiber_coroutine_entry(void);
+
+/* The coroutine is going away while the fiber still points at it. */
+static void zend_fiber_coroutine_dispose(zend_coroutine_t *coroutine)
+{
+ zend_fiber *fiber = coroutine->extended_data;
+
+ if (fiber == NULL) {
+ return;
+ }
+
+ coroutine->extended_data = NULL;
+ fiber->coroutine = NULL;
+}
+
+/* The fiber lets go of its coroutine. Ownership is one-way (fiber → coroutine,
+ * never back), which keeps the fiber collectable. A fiber that dies while its
+ * body is still parked cancels it — the body unwinds through its finally
+ * blocks, like a force-closed legacy fiber. */
+static void zend_fiber_release_coroutine(zend_fiber *fiber)
+{
+ zend_coroutine_t *coroutine = fiber->coroutine;
+
+ if (coroutine == NULL) {
+ return;
+ }
+
+ zend_object *coroutine_object = ZEND_COROUTINE_OBJECT(coroutine);
+
+ coroutine->extended_data = NULL;
+ fiber->coroutine = NULL;
+
+ if (ZEND_ASYNC_ON && ZEND_COROUTINE_IS_STARTED(coroutine)
+ && !ZEND_COROUTINE_IS_FINISHED(coroutine)) {
+ ZEND_ASYNC_CANCEL(coroutine, zend_create_graceful_exit(), true);
+ }
+
+ if (coroutine_object != NULL) {
+ OBJ_RELEASE(coroutine_object);
+ }
+}
+
+static void zend_fiber_coroutine_entry(void)
+{
+ zend_coroutine_t *coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
+ zend_fiber *fiber = coroutine->extended_data;
+ zend_fiber *previous_fiber = EG(active_fiber);
+
+ ZEND_ASSERT(fiber != NULL && "A fiber coroutine must know its fiber");
+
+ /* Inside the body, Fiber::suspend() and Fiber::getCurrent() must see this
+ * fiber, exactly as on the legacy path. */
+ EG(active_fiber) = fiber;
+ fiber->context.status = ZEND_FIBER_STATUS_RUNNING;
+
+ /* The body runs on a stack the scheduler owns, whose bottom frame belongs
+ * to nobody. Link it to the frame that started us, or a backtrace taken
+ * inside the fiber would stop at the fiber and never reach the caller. */
+ fiber->stack_bottom = EG(current_execute_data);
+
+ if (fiber->execute_data != NULL) {
+ fiber->stack_bottom->prev_execute_data = fiber->execute_data;
+ fiber->execute_data = NULL;
+ }
+
+ bool is_bailout = false;
+
+ zend_try {
+ coroutine->fcall->fci.retval = &fiber->result;
+ zend_call_function(&coroutine->fcall->fci, &coroutine->fcall->fci_cache);
+ } zend_catch {
+ is_bailout = true;
+ } zend_end_try();
+
+ EG(active_fiber) = previous_fiber;
+
+ /* A fatal error in the body is the fiber's own outcome: getReturn() has to
+ * be able to tell it apart from a fiber that simply never returned. The
+ * unwinding then goes on to the scheduler. */
+ if (UNEXPECTED(is_bailout)) {
+ fiber = coroutine->extended_data;
+
+ if (fiber != NULL) {
+ fiber->flags |= ZEND_FIBER_FLAG_BAILOUT;
+ fiber->stack_bottom = NULL;
+ fiber->context.status = ZEND_FIBER_STATUS_DEAD;
+ }
+
+ zend_bailout();
+ }
+
+ /* The Fiber object may have been dropped while the body was parked: it is
+ * the coroutine that owns the body, not the object. */
+ fiber = coroutine->extended_data;
+
+ zend_coroutine_t *caller = NULL;
+
+ if (fiber != NULL) {
+ /* The caller's frame may be gone by the time anything looks at this
+ * stack again. */
+ if (fiber->stack_bottom != NULL) {
+ fiber->stack_bottom->prev_execute_data = NULL;
+ fiber->stack_bottom = NULL;
+ }
+
+ fiber->context.status = ZEND_FIBER_STATUS_DEAD;
+
+ caller = fiber->caller_coroutine;
+ fiber->caller_coroutine = NULL;
+ }
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ /* exit(): the scheduler decides what happens to the rest. A graceful
+ * exit is a disposal unwind and must not shut anything down. */
+ if (zend_is_unwind_exit(EG(exception))) {
+ ZEND_ASYNC_SHUTDOWN();
+ }
+
+ if (fiber != NULL) {
+ fiber->flags |= ZEND_FIBER_FLAG_THREW;
+ }
+
+ /* Nobody is waiting: the exception belongs to the coroutine, and the
+ * scheduler decides what to do with it (a graceful exit from a
+ * force-closed fiber included). */
+ if (caller == NULL) {
+ return;
+ }
+
+ zend_object *exception = EG(exception);
+ GC_ADDREF(exception);
+ zend_clear_exception();
+
+ ZEND_ASYNC_ENQUEUE_WITH_ERROR(caller, exception, true);
+ return;
+ }
+
+ if (caller != NULL) {
+ ZEND_ASYNC_ENQUEUE_COROUTINE(caller);
+ }
+}
+
+/* Fiber::suspend() in coroutine mode: hand the value to whoever is waiting,
+ * wake them, and park until somebody resumes us. */
+static void zend_fiber_coroutine_yield(zend_fiber *fiber, zval *value, zval *return_value)
+{
+ zend_coroutine_t *coroutine = fiber->coroutine;
+
+ if (value != NULL) {
+ ZVAL_COPY(&fiber->transfer, value);
+ } else {
+ ZVAL_NULL(&fiber->transfer);
+ }
+
+ zend_coroutine_t *caller = fiber->caller_coroutine;
+ fiber->caller_coroutine = NULL;
+ fiber->context.status = ZEND_FIBER_STATUS_SUSPENDED;
+
+ /* Sever now, not when the caller wakes: GC may walk this parked stack
+ * in between, and it must end at our own root frame. */
+ if (fiber->stack_bottom != NULL) {
+ fiber->stack_bottom->prev_execute_data = NULL;
+ }
+
+ if (caller != NULL) {
+ ZEND_ASYNC_ENQUEUE_COROUTINE(caller);
+ }
+
+ const bool resumed = ZEND_ASYNC_SUSPEND();
+
+ /* Parked all this time; the Fiber object may not have survived — a fiber
+ * nobody holds is force-closed, arriving as a graceful exit thrown here.
+ * Keep EG(active_fiber) off a fiber that is gone. */
+ fiber = coroutine->extended_data;
+ EG(active_fiber) = fiber;
+
+ if (fiber != NULL) {
+ fiber->context.status = ZEND_FIBER_STATUS_RUNNING;
+ }
+
+ /* throw(), or the cancellation of a dropped fiber, lands here. */
+ if (UNEXPECTED(!resumed)) {
+ return;
+ }
+
+ if (UNEXPECTED(fiber == NULL) || Z_ISUNDEF(fiber->transfer)) {
+ ZVAL_NULL(return_value);
+ return;
+ }
+
+ ZVAL_COPY_VALUE(return_value, &fiber->transfer);
+ ZVAL_UNDEF(&fiber->transfer);
+}
+
+/* Wait for the fiber to yield or finish. Returns what it yielded; NULL when
+ * it finished (the return value is getReturn()'s business, as ever). */
+static zend_result zend_fiber_await(zend_fiber *fiber, zval *return_value)
+{
+ zend_coroutine_t *caller = ZEND_ASYNC_CURRENT_COROUTINE;
+
+ ZEND_ASSERT(caller != NULL && "A scheduler is active, so there is a current coroutine");
+
+ if (UNEXPECTED(caller == fiber->coroutine)) {
+ zend_throw_error(zend_ce_fiber_error, "Cannot await a fiber from within itself");
+ return FAILURE;
+ }
+
+ fiber->caller_coroutine = caller;
+
+ /* Hand the body the frame it was started from, so a backtrace taken
+ * inside the fiber continues into its caller. Before the first run there
+ * is no stack to link to yet, and the entry point does it instead. */
+ if (fiber->stack_bottom != NULL) {
+ fiber->stack_bottom->prev_execute_data = EG(current_execute_data);
+ } else {
+ fiber->execute_data = EG(current_execute_data);
+ }
+
+ ZVAL_NULL(return_value);
+
+ /* Parked until the fiber hands control back. An exception that escaped the
+ * fiber is delivered here, at the start()/resume() call site. */
+ if (UNEXPECTED(!ZEND_ASYNC_SUSPEND())) {
+ fiber->caller_coroutine = NULL;
+
+ if (fiber->stack_bottom != NULL) {
+ fiber->stack_bottom->prev_execute_data = NULL;
+ }
+
+ return FAILURE;
+ }
+
+ fiber->caller_coroutine = NULL;
+
+ /* Our frame stops being the fiber's parent the moment it parks again. */
+ if (fiber->stack_bottom != NULL) {
+ fiber->stack_bottom->prev_execute_data = NULL;
+ }
+
+ if (!Z_ISUNDEF(fiber->transfer)) {
+ ZVAL_COPY_VALUE(return_value, &fiber->transfer);
+ ZVAL_UNDEF(&fiber->transfer);
+ }
+
+ return SUCCESS;
+}
+
+/* Offer the starting fiber to the scheduler. It answers with a coroutine to
+ * run the fiber as, or with NULL to leave it on the legacy path (that is how
+ * a scheduler keeps its own fibers to itself). */
+static bool zend_fiber_adopt(zend_fiber *fiber)
+{
+ if (!ZEND_ASYNC_ON) {
+ return false;
+ }
+
+ /* A registered scheduler launches once, before the script's first line, so
+ * it is never still READY here; ZEND_ASYNC_INTERCEPT_FIBER() falls back to
+ * the legacy path on its own if needed. */
+ zend_coroutine_t *coroutine = ZEND_ASYNC_INTERCEPT_FIBER(fiber);
+
+ if (coroutine == NULL) {
+ return false;
+ }
+
+ coroutine->internal_entry = zend_fiber_coroutine_entry;
+ coroutine->extended_data = fiber;
+ coroutine->extended_dispose = zend_fiber_coroutine_dispose;
+ ZEND_COROUTINE_SET_FIBER(coroutine);
+
+ /* The fiber shares ownership of the coroutine with the scheduler. Never
+ * the other way round: see zend_fiber_release_coroutine(). */
+ zend_object *coroutine_object = ZEND_COROUTINE_OBJECT(coroutine);
+
+ if (coroutine_object != NULL) {
+ GC_ADDREF(coroutine_object);
+ }
+
+ fiber->coroutine = coroutine;
+
+ return true;
+}
+
+/* Queue the body and wait for the first yield. */
+static void zend_fiber_coroutine_start(zend_fiber *fiber, zval *return_value)
+{
+ ZEND_ASYNC_FCALL_DEFINE(fcall,
+ fiber->fci,
+ fiber->fci_cache,
+ fiber->fci.params,
+ fiber->fci.param_count,
+ fiber->fci.named_params);
+
+ fiber->coroutine->fcall = fcall;
+
+ if (UNEXPECTED(!ZEND_ASYNC_ENQUEUE_COROUTINE(fiber->coroutine))) {
+ zend_fiber_release_coroutine(fiber);
+ return;
+ }
+
+ zend_fiber_await(fiber, return_value);
+}
+
ZEND_API zend_result zend_fiber_start(zend_fiber *fiber, zval *return_value)
{
ZEND_ASSERT(fiber->context.status == ZEND_FIBER_STATUS_INIT);
@@ -769,6 +1099,15 @@ static void zend_fiber_object_destroy(zend_object *object)
{
zend_fiber *fiber = (zend_fiber *) object;
+ /* Coroutine mode: release unconditionally — even a normally-finished (DEAD)
+ * fiber still holds the scheduler-shared reference and nothing else drops
+ * it. The legacy path below never applies (fiber->context uninitialized). */
+ if (fiber->coroutine != NULL) {
+ fiber->flags |= ZEND_FIBER_FLAG_DESTROYED;
+ zend_fiber_release_coroutine(fiber);
+ return;
+ }
+
if (fiber->context.status != ZEND_FIBER_STATUS_SUSPENDED) {
return;
}
@@ -793,7 +1132,14 @@ static void zend_fiber_object_destroy(zend_object *object)
zend_rethrow_exception(EG(current_execute_data));
}
- zend_exception_set_previous(EG(exception), exception);
+ /* A graceful/unwind exit is an internal marker, not a Throwable that
+ * carries a $previous — chaining onto it creates a bogus dynamic
+ * property (and a deprecation notice). */
+ if (!zend_is_graceful_exit(EG(exception)) && !zend_is_unwind_exit(EG(exception))) {
+ zend_exception_set_previous(EG(exception), exception);
+ } else if (exception != NULL) {
+ OBJ_RELEASE(exception);
+ }
if (!EG(current_execute_data)) {
zend_exception_error(EG(exception), E_ERROR);
@@ -807,28 +1153,24 @@ static void zend_fiber_object_destroy(zend_object *object)
static void zend_fiber_object_free(zend_object *object)
{
zend_fiber *fiber = (zend_fiber *) object;
+ /* A fiber that was never suspended (so dtor_obj had nothing to close) can
+ * still own a coroutine: one that never started, or one that already
+ * finished. */
+ zend_fiber_release_coroutine(fiber);
zval_ptr_dtor(&fiber->fci.function_name);
zval_ptr_dtor(&fiber->result);
+ zval_ptr_dtor(&fiber->transfer);
zend_object_std_dtor(&fiber->std);
}
-static HashTable *zend_fiber_object_gc(zend_object *object, zval **table, int *num)
+/* Walk the frames of a stack parked at a clean Fiber::suspend(): their
+ * variables take part in cycle collection. Returns the last symbol table met. */
+static HashTable *zend_fiber_frames_gc(zend_execute_data *ex, zend_get_gc_buffer *buf)
{
- zend_fiber *fiber = (zend_fiber *) object;
- zend_get_gc_buffer *buf = zend_get_gc_buffer_create();
-
- zend_get_gc_buffer_add_zval(buf, &fiber->fci.function_name);
- zend_get_gc_buffer_add_zval(buf, &fiber->result);
-
- if (fiber->context.status != ZEND_FIBER_STATUS_SUSPENDED || fiber->caller != NULL) {
- zend_get_gc_buffer_use(buf, table, num);
- return NULL;
- }
-
HashTable *lastSymTable = NULL;
- zend_execute_data *ex = fiber->execute_data;
+
for (; ex; ex = ex->prev_execute_data) {
HashTable *symTable;
if (ZEND_CALL_INFO(ex) & ZEND_CALL_GENERATOR) {
@@ -863,6 +1205,71 @@ static HashTable *zend_fiber_object_gc(zend_object *object, zval **table, int *n
}
}
+ return lastSymTable;
+}
+
+static HashTable *zend_fiber_object_gc(zend_object *object, zval **table, int *num)
+{
+ zend_fiber *fiber = (zend_fiber *) object;
+ zend_get_gc_buffer *buf = zend_get_gc_buffer_create();
+
+ /* Coroutine-mode fiber. Running or awaiting inside the body — a GC root:
+ * the stack is mid-operation, scanning it is unsafe. Parked at a clean
+ * Fiber::suspend() (context.status is set only by the yield path) — the
+ * stack is passive and its frames join cycle collection, like a legacy
+ * suspended fiber. */
+ if (fiber->coroutine != NULL) {
+ if (ZEND_COROUTINE_IS_FINISHED(fiber->coroutine)) {
+ zend_get_gc_buffer_add_zval(buf, &fiber->fci.function_name);
+ zend_get_gc_buffer_add_zval(buf, &fiber->result);
+ zend_get_gc_buffer_add_zval(buf, &fiber->transfer);
+
+ zend_object *coroutine_object = ZEND_COROUTINE_OBJECT(fiber->coroutine);
+
+ if (coroutine_object != NULL) {
+ zend_get_gc_buffer_add_obj(buf, coroutine_object);
+ }
+
+ zend_get_gc_buffer_use(buf, table, num);
+ return NULL;
+ }
+
+ if (fiber->context.status == ZEND_FIBER_STATUS_SUSPENDED) {
+ zend_get_gc_buffer_add_zval(buf, &fiber->fci.function_name);
+ zend_get_gc_buffer_add_zval(buf, &fiber->transfer);
+
+ /* The coroutine object stays out of the buffer: the scheduler
+ * holds references GC cannot see, exposing it would miscount. */
+ HashTable *lastSymTable = zend_fiber_frames_gc(
+ ZEND_ASYNC_COROUTINE_EXECUTE_DATA(fiber->coroutine), buf);
+
+ zend_get_gc_buffer_use(buf, table, num);
+ return lastSymTable;
+ }
+
+ zend_get_gc_buffer_use(buf, table, num);
+ return NULL;
+ }
+
+ zend_get_gc_buffer_add_zval(buf, &fiber->fci.function_name);
+ zend_get_gc_buffer_add_zval(buf, &fiber->result);
+ zend_get_gc_buffer_add_zval(buf, &fiber->transfer);
+
+ zend_execute_data *ex;
+
+ if (fiber->context.status == ZEND_FIBER_STATUS_SUSPENDED && fiber->caller == NULL) {
+ ex = fiber->execute_data;
+ } else {
+ ex = NULL;
+ }
+
+ if (ex == NULL) {
+ zend_get_gc_buffer_use(buf, table, num);
+ return NULL;
+ }
+
+ HashTable *lastSymTable = zend_fiber_frames_gc(ex, buf);
+
zend_get_gc_buffer_use(buf, table, num);
return lastSymTable;
@@ -909,6 +1316,22 @@ ZEND_METHOD(Fiber, start)
RETURN_THROWS();
}
+ /* A scheduler gets to claim the fiber here, once, and then it is a
+ * coroutine: no context of its own is ever created. */
+ if (zend_fiber_adopt(fiber)) {
+ zend_fiber_coroutine_start(fiber, return_value);
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ RETURN_THROWS();
+ }
+
+ return;
+ }
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ RETURN_THROWS();
+ }
+
if (zend_fiber_init_context(&fiber->context, zend_ce_fiber, zend_fiber_execute, EG(fiber_stack_size)) == FAILURE) {
RETURN_THROWS();
}
@@ -929,6 +1352,31 @@ ZEND_METHOD(Fiber, suspend)
Z_PARAM_ZVAL(value);
ZEND_PARSE_PARAMETERS_END();
+ const zend_coroutine_t *current = ZEND_ASYNC_CURRENT_COROUTINE;
+ const zend_fiber *active = EG(active_fiber);
+ const bool in_legacy_fiber = active != NULL && active->coroutine == NULL;
+
+ if (!in_legacy_fiber && current != NULL) {
+ if (UNEXPECTED(!ZEND_COROUTINE_IS_FIBER(current))) {
+ zend_throw_error(zend_ce_fiber_error, "Cannot suspend outside of a fiber");
+ RETURN_THROWS();
+ }
+
+ if (UNEXPECTED(zend_fiber_switch_blocked())) {
+ zend_throw_error(zend_ce_fiber_error, "Cannot switch fibers in current execution context");
+ RETURN_THROWS();
+ }
+
+ if (UNEXPECTED(current->extended_data == NULL || ZEND_COROUTINE_IS_CANCELLED(current))) {
+ zend_throw_error(zend_ce_fiber_error, "Cannot suspend in a force-closed fiber");
+ RETURN_THROWS();
+ }
+
+ zend_fiber_coroutine_yield(current->extended_data, value, return_value);
+
+ return;
+ }
+
zend_fiber *fiber = EG(active_fiber);
if (UNEXPECTED(!fiber)) {
@@ -977,6 +1425,21 @@ ZEND_METHOD(Fiber, resume)
RETURN_THROWS();
}
+ if (fiber->coroutine != NULL) {
+ if (value != NULL) {
+ ZVAL_COPY(&fiber->transfer, value);
+ } else {
+ ZVAL_NULL(&fiber->transfer);
+ }
+
+ if (UNEXPECTED(!ZEND_ASYNC_ENQUEUE_COROUTINE(fiber->coroutine))
+ || UNEXPECTED(zend_fiber_await(fiber, return_value) == FAILURE)) {
+ RETURN_THROWS();
+ }
+
+ return;
+ }
+
fiber->stack_bottom->prev_execute_data = EG(current_execute_data);
zend_fiber_transfer transfer = zend_fiber_resume_internal(fiber, value, false);
@@ -1005,6 +1468,15 @@ ZEND_METHOD(Fiber, throw)
RETURN_THROWS();
}
+ if (fiber->coroutine != NULL) {
+ if (UNEXPECTED(!ZEND_ASYNC_ENQUEUE_WITH_ERROR(fiber->coroutine, Z_OBJ_P(exception), false))
+ || UNEXPECTED(zend_fiber_await(fiber, return_value) == FAILURE)) {
+ RETURN_THROWS();
+ }
+
+ return;
+ }
+
fiber->stack_bottom->prev_execute_data = EG(current_execute_data);
zend_fiber_transfer transfer = zend_fiber_resume_internal(fiber, exception, true);
diff --git a/Zend/zend_fibers.h b/Zend/zend_fibers.h
index c72ffdc8f18e..3334054b7290 100644
--- a/Zend/zend_fibers.h
+++ b/Zend/zend_fibers.h
@@ -20,6 +20,7 @@
#define ZEND_FIBERS_H
#include "zend_API.h"
+#include "zend_async_API.h"
#include "zend_types.h"
#define ZEND_FIBER_GUARD_PAGES 1
@@ -129,6 +130,28 @@ struct _zend_fiber {
/* Storage for fiber return value. */
zval result;
+
+ /*
+ * Coroutine mode: set when a scheduler adopted this fiber at its start.
+ * The body then runs as a coroutine of that scheduler, on the context the
+ * scheduler hands it — `context` above stays untouched, and start(),
+ * resume(), throw() and Fiber::suspend() route through the scheduler
+ * instead of switching contexts here. NULL leaves every one of them on
+ * the legacy path.
+ *
+ * The coroutine owns a reference to the fiber; the fiber holds none back,
+ * so nothing keeps a cycle alive. When the coroutine finishes, its result
+ * moves into `result` and this pointer is cleared: from then on a
+ * terminated coroutine-mode fiber is indistinguishable from a legacy one.
+ */
+ zend_coroutine_t *coroutine;
+
+ /* The coroutine waiting for this fiber to yield or finish. */
+ zend_coroutine_t *caller_coroutine;
+
+ /* Coroutine mode: the value crossing between the fiber and its awaiter —
+ * what Fiber::suspend() yields, and what resume() sends back. */
+ zval transfer;
};
ZEND_API zend_result zend_fiber_start(zend_fiber *fiber, zval *return_value);
@@ -139,6 +162,13 @@ ZEND_API void zend_fiber_suspend(zend_fiber *fiber, zval *value, zval *return_va
ZEND_API zend_result zend_fiber_init_context(zend_fiber_context *context, void *kind, zend_fiber_coroutine coroutine, size_t stack_size);
ZEND_API void zend_fiber_destroy_context(zend_fiber_context *context);
ZEND_API void zend_fiber_switch_context(zend_fiber_transfer *transfer);
+/* Install a fresh VM stack for a fiber-context body (root frame from
+ * `root_function`, linked to the switching-in frame). The stack stays
+ * reachable through EG(vm_stack) while the body runs and must be captured at
+ * completion and released with zend_fiber_vm_stack_free(). */
+ZEND_API zend_execute_data *zend_fiber_vm_stack_start(
+ zend_fiber_context *context, zend_function *root_function);
+ZEND_API void zend_fiber_vm_stack_free(zend_vm_stack stack);
#ifdef ZEND_CHECK_STACK_LIMIT
ZEND_API void* zend_fiber_stack_limit(zend_fiber_stack *stack);
ZEND_API void* zend_fiber_stack_base(zend_fiber_stack *stack);
diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c
index 5de2b69bf568..a8c1efa9af1d 100644
--- a/Zend/zend_gc.c
+++ b/Zend/zend_gc.c
@@ -70,6 +70,7 @@
#include "zend_compile.h"
#include "zend_errors.h"
#include "zend_fibers.h"
+#include "zend_async_API.h"
#include "zend_hrtime.h"
#include "zend_portability.h"
#include "zend_types.h"
@@ -294,6 +295,14 @@ typedef struct _zend_gc_globals {
zend_fiber *dtor_fiber;
bool dtor_fiber_running;
+ zend_coroutine_t *gc_coroutine;
+ zend_coroutine_t *dtor_coroutine;
+ zend_async_microtask_t *microtask;
+ /* How many destructor-coroutines are still outstanding */
+ uint32_t dtor_pending;
+ /* Result of the last coroutine-run collection, returned to the waiters. */
+ int gc_collected;
+
#if GC_BENCH
uint32_t root_buf_length;
uint32_t root_buf_peak;
@@ -535,6 +544,11 @@ static void gc_globals_ctor_ex(zend_gc_globals *gc_globals)
gc_globals->dtor_end = 0;
gc_globals->dtor_fiber = NULL;
gc_globals->dtor_fiber_running = false;
+ gc_globals->gc_coroutine = NULL;
+ gc_globals->dtor_coroutine = NULL;
+ gc_globals->microtask = NULL;
+ gc_globals->dtor_pending = 0;
+ gc_globals->gc_collected = 0;
#if GC_BENCH
gc_globals->root_buf_length = 0;
@@ -1872,6 +1886,7 @@ static zend_always_inline zend_result gc_call_destructors(uint32_t idx, uint32_t
{
gc_root_buffer *current;
zend_refcounted *p;
+ zend_coroutine_t *coroutine = ZEND_ASYNC_IS_ACTIVE ? ZEND_ASYNC_CURRENT_COROUTINE : NULL;
/* The root buffer might be reallocated during destructors calls,
* make sure to reload pointers as necessary. */
@@ -1885,7 +1900,7 @@ static zend_always_inline zend_result gc_call_destructors(uint32_t idx, uint32_t
* could have already been invoked indirectly by some other
* destructor. */
if (!(OBJ_FLAGS(p) & IS_OBJ_DESTRUCTOR_CALLED)) {
- if (fiber != NULL) {
+ if (fiber != NULL || coroutine != NULL) {
GC_G(dtor_idx) = idx;
}
zend_object *obj = (zend_object*)p;
@@ -1900,6 +1915,14 @@ static zend_always_inline zend_result gc_call_destructors(uint32_t idx, uint32_t
gc_check_possible_root((zend_refcounted*)&obj->gc);
return FAILURE;
}
+ if (UNEXPECTED(coroutine != NULL && coroutine != ZEND_ASYNC_CURRENT_COROUTINE)) {
+ /* The destructor suspended: this iterator is parked inside
+ * it. Stop here with the cursor persisted — the armed
+ * microtask spawns a fresh iterator to carry on (see
+ * gc_destructors_iterator_microtask). */
+ gc_check_possible_root((zend_refcounted*)&obj->gc);
+ return FAILURE;
+ }
}
}
idx++;
@@ -1990,9 +2013,227 @@ static zend_never_inline void gc_call_destructors_in_fiber(void)
EG(exception) = exception;
}
+///
+/// GC destructors coroutine
+///
+/// The destructor phase runs in a coroutine (async active), so a destructor
+/// may spawn and await its own work. It is a concurrent iterator over the
+/// garbage buffer, driven by a re-arming microtask:
+///
+/// - an iterator coroutine walks the buffer (gc_call_destructors),
+/// advancing GC_G(dtor_idx);
+/// - it arms GC_G(microtask) first. If a destructor suspends, the parked
+/// iterator can't advance itself, so the microtask fires next tick and
+/// spawns a fresh one to resume from GC_G(dtor_idx) (already-run
+/// destructors skip via IS_OBJ_DESTRUCTOR_CALLED);
+/// - an iterator that finishes its range without suspending cancels it.
+///
+/// GC_G(dtor_pending) counts outstanding iterators (this API has no scope):
+/// GC_G(gc_coroutine) resumes only once all have finished, including any that
+/// suspended and came back.
+///
+
+static zend_coroutine_t *gc_spawn_destructors_coroutine(void);
+
+/* Counts a finished iterator out; wakes the GC coroutine once the last one is
+ * gone. The hand-off itself is the microtask's job, not this one's. */
+static bool gc_destructors_finish_handler(
+ zend_coroutine_t *coroutine, zend_coroutine_t *waiter, void *data, const bool is_bailout)
+{
+ (void) coroutine;
+ (void) waiter;
+ (void) data;
+
+ GC_G(dtor_pending)--;
+
+ if (GC_G(dtor_pending) == 0) {
+ GC_G(dtor_coroutine) = NULL;
+
+ if (!is_bailout && GC_G(gc_coroutine) != NULL) {
+ ZEND_ASYNC_ENQUEUE_COROUTINE(GC_G(gc_coroutine));
+ }
+ }
+
+ return false;
+}
+
+static void gc_destructors_iterator_microtask(zend_async_microtask_t *task)
+{
+ if (UNEXPECTED(gc_spawn_destructors_coroutine() == NULL)) {
+ ZEND_ASYNC_MICROTASK_CANCEL(task);
+ }
+}
+
+static void gc_destructors_microtask_dtor(zend_async_microtask_t *task)
+{
+ if (GC_G(microtask) == task) {
+ GC_G(microtask) = NULL;
+ }
+}
+
+static void gc_arm_iterator_microtask(void)
+{
+ if (GC_G(microtask) == NULL) {
+ zend_async_microtask_t *task = ecalloc(1, sizeof(zend_async_microtask_t));
+ task->handler = gc_destructors_iterator_microtask;
+ task->dtor = gc_destructors_microtask_dtor;
+ task->ref_count = 1;
+ GC_G(microtask) = task;
+ }
+
+ GC_G(microtask)->is_cancelled = 0;
+ ZEND_ASYNC_MICROTASK_ADDREF(GC_G(microtask));
+
+ if (UNEXPECTED(!ZEND_ASYNC_DEFER(GC_G(microtask)))) {
+ ZEND_ASYNC_MICROTASK_RELEASE(GC_G(microtask));
+ }
+}
+
+/* Cancel and drop the iterator microtask: the iteration finished on its own. */
+static void gc_disarm_iterator_microtask(void)
+{
+ if (GC_G(microtask) != NULL) {
+ zend_async_microtask_t *task = GC_G(microtask);
+ GC_G(microtask) = NULL;
+ ZEND_ASYNC_MICROTASK_CANCEL(task);
+ ZEND_ASYNC_MICROTASK_RELEASE(task);
+ }
+}
+
+static void gc_destructors_coroutine(void)
+{
+ GC_TRACE("GC destructors coroutine started");
+
+ gc_arm_iterator_microtask();
+
+ gc_call_destructors(GC_G(dtor_idx), GC_G(dtor_end), NULL);
+
+ /* A destructor suspended and the microtask has already spawned a fresh
+ * iterator that superseded us: leave the microtask armed and bow out —
+ * this one's only remaining job is to finish and be counted out. */
+ if (EXPECTED(!EG(exception)) && GC_G(dtor_coroutine) != ZEND_ASYNC_CURRENT_COROUTINE) {
+ return;
+ }
+
+ gc_disarm_iterator_microtask();
+}
+
+static zend_coroutine_t *gc_spawn_destructors_coroutine(void)
+{
+ zend_coroutine_t *coroutine = ZEND_ASYNC_GC_NEW_COROUTINE();
+
+ if (UNEXPECTED(coroutine == NULL)) {
+ return NULL;
+ }
+
+ coroutine->internal_entry = gc_destructors_coroutine;
+ GC_G(dtor_coroutine) = coroutine;
+ GC_G(dtor_pending)++;
+
+ /* waiter is diagnostic only; the handler works off GC_G. */
+ const uint32_t handler_id = ZEND_ASYNC_ADD_FINISH_HANDLER(
+ coroutine, gc_destructors_finish_handler, GC_G(gc_coroutine), NULL);
+
+ /* Without the handler nobody ever counts this iterator out: treat a failed
+ * registration like a failed enqueue. */
+ if (UNEXPECTED(handler_id == 0 || !ZEND_ASYNC_ENQUEUE_COROUTINE(coroutine))) {
+ ZEND_ASYNC_REMOVE_FINISH_HANDLER(coroutine, handler_id);
+ GC_G(dtor_coroutine) = NULL;
+ GC_G(dtor_pending)--;
+ return NULL;
+ }
+
+ return coroutine;
+}
+
+/*
+ * Start the destructor phase as a coroutine and wait for the iterator chain
+ * to finish. Calling this blocks GC execution (by suspending GC_G(gc_coroutine),
+ * itself a coroutine) until every destructor has run.
+ */
+static zend_never_inline bool gc_call_destructors_in_coroutine(void)
+{
+ GC_G(dtor_idx) = GC_FIRST_ROOT;
+ GC_G(dtor_end) = GC_G(first_unused);
+
+ if (UNEXPECTED(gc_spawn_destructors_coroutine() == NULL)) {
+ return false;
+ }
+
+ return ZEND_ASYNC_SUSPEND();
+}
+
+///
+/// GC coroutine
+///
+/// The run lives in a coroutine (the destructor phase suspends into it). A
+/// caller that is not that coroutine starts one and blocks until it finishes.
+///
+
+static void zend_gc_coroutine(void)
+{
+ GC_TRACE("GC coroutine started");
+ GC_G(gc_collected) = zend_gc_collect_cycles();
+ GC_G(gc_coroutine) = NULL;
+ GC_TRACE("GC coroutine finished");
+}
+
+static zend_always_inline zend_coroutine_t *new_gc_coroutine(void)
+{
+ zend_coroutine_t *coroutine = ZEND_ASYNC_GC_NEW_COROUTINE();
+
+ if (UNEXPECTED(coroutine == NULL)) {
+ return NULL;
+ }
+
+ coroutine->internal_entry = zend_gc_coroutine;
+ GC_G(gc_coroutine) = coroutine;
+
+ if (UNEXPECTED(!ZEND_ASYNC_ENQUEUE_COROUTINE(coroutine))) {
+ GC_G(gc_coroutine) = NULL;
+ return NULL;
+ }
+
+ return coroutine;
+}
+
/* Perform a garbage collection run. The default implementation of gc_collect_cycles. */
ZEND_API int zend_gc_collect_cycles(void)
{
+ if (UNEXPECTED(ZEND_ASYNC_IS_ACTIVE && ZEND_ASYNC_CURRENT_COROUTINE != GC_G(gc_coroutine))) {
+ /* Called from inside the active run (a destructor): the run is
+ * waiting for us, waiting for it back would deadlock. Reentrant
+ * calls get 0, as ever. */
+ if (GC_G(gc_active)) {
+ return 0;
+ }
+
+ /* The run executes on the GC coroutine and cannot see this stack:
+ * shield the caller's live TMPVARs from it for the duration. */
+ if (GC_G(num_roots)) {
+ zend_gc_remove_root_tmpvars();
+ }
+
+ if (GC_G(gc_coroutine) == NULL && UNEXPECTED(new_gc_coroutine() == NULL)) {
+ return 0;
+ }
+
+ /* Synchronous for the caller: parked until the run finishes. false
+ * means this coroutine was cancelled, not that the run failed. */
+ const bool awaited = ZEND_ASYNC_AWAIT(GC_G(gc_coroutine));
+
+ /* Re-root the shielded TMPVARs — on the cancelled path too, or they
+ * fall out of GC tracking. gc_active keeps this walk from starting a
+ * fresh run; by wake-up one may already be active, so restore rather
+ * than reset. */
+ bool was_active = GC_G(gc_active);
+ GC_G(gc_active) = 1;
+ zend_gc_check_root_tmpvars();
+ GC_G(gc_active) = was_active;
+
+ return awaited ? GC_G(gc_collected) : 0;
+ }
+
int total_count = 0;
bool should_rerun_gc = false;
bool did_rerun_gc = false;
@@ -2091,8 +2332,35 @@ ZEND_API int zend_gc_collect_cycles(void)
/* Actually call destructors. */
zend_hrtime_t dtor_start_time = zend_hrtime();
- if (EXPECTED(!EG(active_fiber))) {
+ const bool is_async = ZEND_ASYNC_IS_ACTIVE;
+ if (EXPECTED(!EG(active_fiber) && !is_async)) {
gc_call_destructors(GC_FIRST_ROOT, end, NULL);
+ } else if (is_async) {
+ if (UNEXPECTED(!gc_call_destructors_in_coroutine())) {
+ if (EG(exception)) {
+ /* No rerun: a cancelled coroutine cannot park again, and
+ * a rerun would leave a stray iterator over a reset
+ * buffer. */
+ should_rerun_gc = false;
+
+ if (instanceof_function(EG(exception)->ce,
+ ZEND_ASYNC_GET_EXCEPTION_CE(ZEND_ASYNC_EXCEPTION_CANCELLATION))) {
+ zend_clear_exception();
+ }
+ }
+
+ /* Clean up GC_DTOR_GARBAGE tags left behind: the iterator
+ * may have stopped partway through the buffer. */
+ idx = GC_G(dtor_idx);
+ current = GC_IDX2PTR(idx);
+ while (idx != end) {
+ if (GC_IS_DTOR_GARBAGE(current->ref)) {
+ current->ref = GC_GET_PTR(current->ref);
+ }
+ current++;
+ idx++;
+ }
+ }
} else {
gc_call_destructors_in_fiber();
}
diff --git a/Zend/zend_globals.h b/Zend/zend_globals.h
index 83360a2c96d2..11fc5616923e 100644
--- a/Zend/zend_globals.h
+++ b/Zend/zend_globals.h
@@ -169,6 +169,23 @@ struct _zend_compiler_globals {
#endif
};
+/* Coroutine relay state for shutdown_destructors() / zend_objects_store_
+ * call_destructors_async(). The passes run in sequence, never concurrently,
+ * and share the cursor. A switch handler can outlive its pass (it only
+ * self-drops on a switch-out), so handlers check `pass`: a stale one must
+ * not act on the other pass's cursor. */
+typedef enum {
+ ZEND_SHUTDOWN_PASS_NONE = 0,
+ ZEND_SHUTDOWN_PASS_SYMBOLS,
+ ZEND_SHUTDOWN_PASS_OBJECTS,
+} zend_shutdown_pass_t;
+
+typedef struct {
+ zend_shutdown_pass_t pass;
+ void *coroutine;
+ uint32_t num_elements;
+ uint32_t idx;
+} zend_shutdown_context_t;
struct _zend_executor_globals {
zval uninitialized_zval;
@@ -183,6 +200,8 @@ struct _zend_executor_globals {
zend_array symbol_table; /* main symbol table */
+ zend_shutdown_context_t shutdown_context;
+
HashTable included_files; /* files already included */
JMP_BUF *bailout;
diff --git a/Zend/zend_objects_API.c b/Zend/zend_objects_API.c
index 537cad8a3644..f763f73e4d34 100644
--- a/Zend/zend_objects_API.c
+++ b/Zend/zend_objects_API.c
@@ -23,6 +23,8 @@
#include "zend_API.h"
#include "zend_objects_API.h"
#include "zend_fibers.h"
+#include "zend_async_API.h"
+#include "zend_execute.h"
ZEND_API void ZEND_FASTCALL zend_objects_store_init(zend_objects_store *objects, uint32_t init_size)
{
@@ -61,6 +63,115 @@ ZEND_API void ZEND_FASTCALL zend_objects_store_call_destructors(zend_objects_sto
}
}
+/* Continue the pass in a fresh coroutine, picking up wherever the
+ * interrupted one left off. */
+static void zend_objects_store_call_destructors_async_iterator_entry(void)
+{
+ zend_objects_store_call_destructors_async(&EG(objects_store));
+}
+
+/* Reset the shutdown cursor if the iterator coroutine is torn down mid-pass
+ * (cancelled/errored) instead of finishing normally — otherwise the pass
+ * stays marked in flight forever. Dispose may run inside a bailout: no user
+ * code from here, the remaining destructors are given up. */
+static void zend_objects_store_call_destructors_async_coroutine_dtor(zend_coroutine_t *coroutine)
+{
+ if (EG(shutdown_context).coroutine == coroutine) {
+ EG(shutdown_context).coroutine = NULL;
+ EG(shutdown_context).pass = ZEND_SHUTDOWN_PASS_NONE;
+ zend_error(E_WARNING, "Object store destructors coroutine was not finished properly");
+ zend_objects_store_mark_destructed(&EG(objects_store));
+ }
+}
+
+/* On the driving coroutine's leave (a destructor suspended), continue the
+ * pass in a fresh iterator. Synchronous, not a microtask: this runs late in
+ * shutdown, where a next tick may never come. */
+static bool zend_objects_store_call_destructors_async_switch_handler(zend_coroutine_t *coroutine, bool is_enter)
+{
+ (void) coroutine;
+
+ if (is_enter) {
+ return true;
+ }
+
+ if (EG(shutdown_context).pass != ZEND_SHUTDOWN_PASS_OBJECTS) {
+ return false;
+ }
+
+ zend_coroutine_t *iterator = ZEND_ASYNC_GC_NEW_COROUTINE();
+
+ if (UNEXPECTED(iterator == NULL)) {
+ return false;
+ }
+
+ iterator->internal_entry = zend_objects_store_call_destructors_async_iterator_entry;
+ iterator->extended_dispose = zend_objects_store_call_destructors_async_coroutine_dtor;
+
+ ZEND_ASYNC_ENQUEUE_COROUTINE(iterator);
+
+ return false;
+}
+
+ZEND_API void ZEND_FASTCALL zend_objects_store_call_destructors_async(zend_objects_store *objects)
+{
+ if (objects->top <= 1) {
+ return;
+ }
+
+ EG(flags) |= EG_FLAGS_OBJECT_STORE_NO_REUSE;
+
+ zend_coroutine_t *coroutine = ZEND_ASYNC_IS_ACTIVE ? ZEND_ASYNC_CURRENT_COROUTINE : NULL;
+
+ if (coroutine != NULL) {
+ ZEND_ASYNC_ADD_SWITCH_HANDLER(
+ coroutine, zend_objects_store_call_destructors_async_switch_handler);
+ }
+
+ if (EG(shutdown_context).pass != ZEND_SHUTDOWN_PASS_OBJECTS) {
+ EG(shutdown_context).pass = ZEND_SHUTDOWN_PASS_OBJECTS;
+ EG(shutdown_context).coroutine = coroutine;
+ EG(shutdown_context).idx = 1;
+ }
+
+ /* Under a scheduler, skip live fibers and coroutine objects: a destructor
+ * may spawn a fiber and await it, and destroying that still-parked fiber
+ * would break the destructor about to resume it. The drain tears them down
+ * later. Without a scheduler, fibers take the upstream path: the dtor
+ * force-closes them (the parked GC destructor fiber relies on it). */
+ const bool skip_flows = ZEND_ASYNC_IS_ACTIVE;
+ zend_class_entry *coroutine_ce =
+ skip_flows ? ZEND_ASYNC_GET_CE(ZEND_ASYNC_CLASS_COROUTINE) : NULL;
+
+ for (uint32_t i = EG(shutdown_context).idx; i < objects->top; i++) {
+ zend_object *obj = objects->object_buckets[i];
+
+ if (IS_OBJ_VALID(obj)
+ && (!skip_flows || (obj->ce != zend_ce_fiber && obj->ce != coroutine_ce))) {
+ if (!(OBJ_FLAGS(obj) & IS_OBJ_DESTRUCTOR_CALLED)) {
+ GC_ADD_FLAGS(obj, IS_OBJ_DESTRUCTOR_CALLED);
+
+ if (obj->handlers->dtor_obj != zend_objects_destroy_object
+ || obj->ce->destructor) {
+ EG(shutdown_context).idx = i;
+ GC_ADDREF(obj);
+ obj->handlers->dtor_obj(obj);
+ GC_DELREF(obj);
+
+ /* Destructor suspended (current coroutine changed): stop —
+ * the switch handler spawned an iterator to carry on. */
+ if (coroutine != NULL && coroutine != ZEND_ASYNC_CURRENT_COROUTINE) {
+ return;
+ }
+ }
+ }
+ }
+ }
+
+ EG(shutdown_context).pass = ZEND_SHUTDOWN_PASS_NONE;
+ EG(shutdown_context).coroutine = NULL;
+}
+
ZEND_API void ZEND_FASTCALL zend_objects_store_mark_destructed(zend_objects_store *objects)
{
if (objects->object_buckets && objects->top > 1) {
diff --git a/Zend/zend_objects_API.h b/Zend/zend_objects_API.h
index 434ac4499e7f..50fbc91f656b 100644
--- a/Zend/zend_objects_API.h
+++ b/Zend/zend_objects_API.h
@@ -54,6 +54,7 @@ typedef struct _zend_objects_store {
BEGIN_EXTERN_C()
ZEND_API void ZEND_FASTCALL zend_objects_store_init(zend_objects_store *objects, uint32_t init_size);
ZEND_API void ZEND_FASTCALL zend_objects_store_call_destructors(zend_objects_store *objects);
+ZEND_API void ZEND_FASTCALL zend_objects_store_call_destructors_async(zend_objects_store *objects);
ZEND_API void ZEND_FASTCALL zend_objects_store_mark_destructed(zend_objects_store *objects);
ZEND_API void ZEND_FASTCALL zend_objects_store_free_object_storage(zend_objects_store *objects, bool fast_shutdown);
ZEND_API void ZEND_FASTCALL zend_objects_store_destroy(zend_objects_store *objects);
diff --git a/configure.ac b/configure.ac
index ec64637b186b..462fae428aec 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1766,6 +1766,7 @@ PHP_ADD_SOURCES([Zend], m4_normalize([
zend_call_stack.c
zend_closures.c
zend_compile.c
+ zend_async_API.c
zend_constants.c
zend_cpuinfo.c
zend_default_classes.c
diff --git a/ext/test_scheduler/config.m4 b/ext/test_scheduler/config.m4
new file mode 100644
index 000000000000..7ec5b6f667de
--- /dev/null
+++ b/ext/test_scheduler/config.m4
@@ -0,0 +1,13 @@
+PHP_ARG_ENABLE([test-scheduler],
+ [whether to enable the test_scheduler extension],
+ [AS_HELP_STRING([--enable-test-scheduler],
+ [Enable the test_scheduler extension: a reference C scheduler for the Async Core hooks (testing only)])],
+ [no])
+
+if test "$PHP_TEST_SCHEDULER" != "no"; then
+ AC_DEFINE([HAVE_TEST_SCHEDULER], [1],
+ [Define to 1 if the PHP extension 'test_scheduler' is available.])
+ PHP_NEW_EXTENSION([test_scheduler], [test_scheduler.c],
+ [$ext_shared],,
+ [-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1])
+fi
diff --git a/ext/test_scheduler/config.w32 b/ext/test_scheduler/config.w32
new file mode 100644
index 000000000000..2abcb682429c
--- /dev/null
+++ b/ext/test_scheduler/config.w32
@@ -0,0 +1,6 @@
+ARG_ENABLE("test-scheduler", "test_scheduler extension: a reference C scheduler for the Async Core hooks (testing only)", "no");
+
+if (PHP_TEST_SCHEDULER != "no") {
+ EXTENSION("test_scheduler", "test_scheduler.c", null, "/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1");
+ AC_DEFINE("HAVE_TEST_SCHEDULER", 1, "Define to 1 if the PHP extension 'test_scheduler' is available.");
+}
diff --git a/ext/test_scheduler/php_test_scheduler.h b/ext/test_scheduler/php_test_scheduler.h
new file mode 100644
index 000000000000..431dc15f9ac8
--- /dev/null
+++ b/ext/test_scheduler/php_test_scheduler.h
@@ -0,0 +1,22 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Authors: Edmond |
+ +----------------------------------------------------------------------+
+*/
+#ifndef PHP_TEST_SCHEDULER_H
+#define PHP_TEST_SCHEDULER_H
+
+extern zend_module_entry test_scheduler_module_entry;
+#define phpext_test_scheduler_ptr &test_scheduler_module_entry
+
+#define PHP_TEST_SCHEDULER_VERSION "0.1.0"
+
+#endif /* PHP_TEST_SCHEDULER_H */
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
new file mode 100644
index 000000000000..1147ed600c44
--- /dev/null
+++ b/ext/test_scheduler/test_scheduler.c
@@ -0,0 +1,1964 @@
+/*
+ +----------------------------------------------------------------------+
+ | Copyright © The PHP Group and Contributors. |
+ +----------------------------------------------------------------------+
+ | This source file is subject to the Modified BSD License that is |
+ | bundled with this package in the file LICENSE, and is available |
+ | through the World Wide Web at . |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Authors: Edmond |
+ +----------------------------------------------------------------------+
+*/
+
+/*
+ * A reference scheduler for the Async Core hooks: the smallest thing that
+ * can run coroutines, and nothing more. No reactor, no timers, no context
+ * pool — the point is to show where the seams of the core API are.
+ *
+ * The execution model is the one the core prescribes:
+ *
+ * - a coroutine owns its fiber context, embedded by value, so the engine
+ * never allocates anything on the scheduler's behalf;
+ * - the top-level script becomes the main coroutine by adopting the
+ * context the engine already built for it (a copy of
+ * *EG(main_fiber_context)), not by growing a second kind of object;
+ * - the loop lives in a coroutine of its own: every switch is then just a
+ * switch between two coroutines, main included;
+ * - who is running is recorded in ZEND_ASYNC_CURRENT_COROUTINE before
+ * every switch, and that is how an entry point learns which coroutine
+ * it is.
+ */
+
+#ifdef HAVE_CONFIG_H
+#include
+#endif
+
+#include "php.h"
+#include "zend_async_API.h"
+#include "zend_exceptions.h"
+#include "zend_fibers.h"
+#include "zend_ini.h"
+#include "zend_observer.h"
+
+#include "php_test_scheduler.h"
+#include "test_scheduler_arginfo.h"
+
+typedef struct _ts_coroutine_s ts_coroutine_t;
+
+/*
+ * Growable handler vectors. Capacity doubles on overflow, add() dedupes,
+ * call() compacts in place by keeping only the handlers that stay armed
+ * (return true). in_execution guards against a handler mutating the vector
+ * being iterated. Finish handlers carry their registration context
+ * (waiter, data) by value; the vector dies with the coroutine.
+ */
+typedef void (*ts_handler_fn)(void);
+
+typedef struct {
+ uint32_t length;
+ uint32_t capacity;
+ ts_handler_fn *data;
+ bool in_execution;
+} ts_handlers_vector_t;
+
+typedef struct {
+ zend_coroutine_finish_handler_fn handler;
+ zend_coroutine_t *waiter;
+ void *data;
+} ts_finish_handler_t;
+
+typedef struct {
+ uint32_t length;
+ uint32_t capacity;
+ ts_finish_handler_t *data;
+ bool in_execution;
+} ts_finish_handlers_vector_t;
+
+/* One "what am I waiting for" registration. The vector is cleared whole at
+ * enqueue: a runnable coroutine's wait descriptions are all stale. */
+typedef struct {
+ zend_coroutine_awaiting_info_fn handler;
+ void *data;
+} ts_awaiting_info_t;
+
+typedef struct {
+ uint32_t length;
+ uint32_t capacity;
+ ts_awaiting_info_t *data;
+ bool in_execution;
+} ts_awaiting_info_vector_t;
+
+/*
+ * `coro` stays first: the engine speaks zend_coroutine_t* and the two
+ * pointers are interchangeable. The fiber context is embedded by value, as
+ * in zend_fiber itself.
+ */
+struct _ts_coroutine_s {
+ zend_coroutine_t coro;
+ zend_fiber_context context;
+ /* The context is a copy of the engine's main context: the stack behind
+ * it is the OS thread stack, and destroying it would free memory we do
+ * not own. */
+ bool context_is_main;
+ bool context_created;
+ /* Thrown into the coroutine at the next switch into it. Owned. */
+ zend_object *pending_error;
+ /* The body's VM stack, captured when it returns. */
+ zend_vm_stack vm_stack;
+ /* The frame the coroutine is parked in while suspended: the engine reaches
+ * the stack through it (garbage collection, backtraces). */
+ zend_execute_data *execute_data;
+ /* All three lazily allocated (NULL until the first add) — the common case
+ * is nobody watching a given coroutine at all. */
+ ts_handlers_vector_t *switch_handlers;
+ ts_finish_handlers_vector_t *finish_handlers;
+ ts_awaiting_info_vector_t *awaiting_info;
+ /* Coroutines parked in await() on this one, woken when it finishes.
+ * Borrowed pointers: an awaiter cannot go away while suspended — the live
+ * table holds a reference. Lazily allocated. */
+ struct _ts_coroutine_s **waiters;
+ uint32_t waiters_length;
+ uint32_t waiters_capacity;
+ /* Somebody awaited the outcome: the exception is theirs, not "unhandled". */
+ bool exception_observed;
+ zend_object std;
+};
+
+/* The run queue: a ring buffer of ready coroutines. spawn()/resume() append,
+ * the loop takes from the head; FIFO is the whole policy. Raw pointers — the
+ * live table owns the reference, the queue only borrows it. */
+typedef struct {
+ ts_coroutine_t **data;
+ size_t head;
+ size_t count;
+ size_t capacity;
+} ts_fifo_t;
+
+/* Same shape, for microtasks: ZEND_ASYNC_DEFER() queues here, and the loop
+ * drains it once per tick, before running the next ready coroutine. */
+typedef struct {
+ zend_async_microtask_t **data;
+ size_t head;
+ size_t count;
+ size_t capacity;
+} ts_microtask_fifo_t;
+
+ZEND_BEGIN_MODULE_GLOBALS(test_scheduler)
+ ts_fifo_t queue;
+ ts_microtask_fifo_t microtasks;
+ /* Every coroutine that has not finished, one reference each. */
+ HashTable coroutines;
+ /* The loop. It is not a task, so it lives here and not in the table. */
+ ts_coroutine_t *scheduler;
+ZEND_END_MODULE_GLOBALS(test_scheduler)
+
+ZEND_DECLARE_MODULE_GLOBALS(test_scheduler)
+
+#define TSG(v) ZEND_MODULE_GLOBALS_ACCESSOR(test_scheduler, v)
+
+/* Off by default: the extension claims the process-wide scheduler slots, and
+ * a binary carrying it must still be able to test other providers. Tests opt
+ * in with test_scheduler.enable=1. */
+PHP_INI_BEGIN()
+ PHP_INI_ENTRY("test_scheduler.enable", "0", PHP_INI_SYSTEM, NULL)
+PHP_INI_END()
+
+/* False when disabled: MINIT registered nothing. */
+static bool ts_registered = false;
+
+static zend_class_entry *ts_ce_coroutine;
+static zend_class_entry *ts_ce_cancellation_error;
+static zend_class_entry *ts_ce_deadlock_error;
+static zend_object_handlers ts_coroutine_handlers;
+
+/* The bottom frame of every coroutine's VM stack. Nameless on purpose: a
+ * frame with no function name is a dummy frame, and backtraces skip it — the
+ * engine's own fiber root frame is built exactly this way. */
+static zend_function ts_root_function = { ZEND_INTERNAL_FUNCTION };
+
+static zend_always_inline ts_coroutine_t *ts_from_obj(zend_object *object)
+{
+ return (ts_coroutine_t *) ((char *) object - offsetof(ts_coroutine_t, std));
+}
+
+static zend_always_inline ts_coroutine_t *ts_from_coro(zend_coroutine_t *coro)
+{
+ return (ts_coroutine_t *) coro;
+}
+
+static bool ts_enqueue(zend_coroutine_t *coroutine, zend_object *error, bool transfer_error);
+
+///////////////////////////////////////////////////////////////////
+/// Switch/finish handler vectors
+///////////////////////////////////////////////////////////////////
+
+static uint32_t ts_handlers_add(ts_handlers_vector_t **vector_ptr, ts_handler_fn handler, const char *what)
+{
+ if (*vector_ptr == NULL) {
+ *vector_ptr = ecalloc(1, sizeof(ts_handlers_vector_t));
+ }
+
+ ts_handlers_vector_t *vector = *vector_ptr;
+
+ if (UNEXPECTED(vector->in_execution)) {
+ zend_error(E_WARNING, "Cannot add a %s handler while handlers are running", what);
+ return 0;
+ }
+
+ for (uint32_t i = 0; i < vector->length; i++) {
+ if (vector->data[i] == handler) {
+ return i + 1;
+ }
+ }
+
+ if (vector->length == vector->capacity) {
+ vector->capacity = vector->capacity ? vector->capacity * 2 : 4;
+ vector->data = safe_erealloc(vector->data, vector->capacity, sizeof(ts_handler_fn), 0);
+ }
+
+ vector->data[vector->length] = handler;
+ vector->length++;
+
+ /* 1-based: 0 is reserved for "nothing to remove" (see the ZEND_ASYNC_ADD_*
+ * failure case). */
+ return vector->length;
+}
+
+static bool ts_handlers_remove(ts_handlers_vector_t *vector, uint32_t handler_id, const char *what)
+{
+ if (vector == NULL || handler_id == 0 || handler_id > vector->length) {
+ return false;
+ }
+
+ if (UNEXPECTED(vector->in_execution)) {
+ zend_error(E_WARNING, "Cannot remove a %s handler while handlers are running", what);
+ return false;
+ }
+
+ for (uint32_t i = handler_id - 1; i < vector->length - 1; i++) {
+ vector->data[i] = vector->data[i + 1];
+ }
+
+ vector->length--;
+
+ return true;
+}
+
+static void ts_handlers_free(ts_handlers_vector_t *vector)
+{
+ if (vector == NULL) {
+ return;
+ }
+
+ if (vector->data != NULL) {
+ efree(vector->data);
+ }
+
+ efree(vector);
+}
+
+/* Ask each handler whether it wants to stay armed; the ones that return false
+ * are dropped, in place, in the same pass. Only the invocation differs between
+ * the two lists, so the storage helpers above are shared and these are not. */
+static void ts_coroutine_call_switch_handlers(ts_coroutine_t *ts, bool is_enter)
+{
+ ts_handlers_vector_t *vector = ts->switch_handlers;
+
+ if (vector == NULL || vector->length == 0) {
+ return;
+ }
+
+ vector->in_execution = true;
+
+ uint32_t write_index = 0;
+
+ for (uint32_t read_index = 0; read_index < vector->length; read_index++) {
+ zend_coroutine_switch_handler_fn handler =
+ (zend_coroutine_switch_handler_fn) vector->data[read_index];
+
+ if (handler(&ts->coro, is_enter)) {
+ vector->data[write_index++] = vector->data[read_index];
+ }
+ }
+
+ vector->length = write_index;
+ vector->in_execution = false;
+}
+
+/* Every way a coroutine can end comes through here; each entry runs once. */
+static void ts_coroutine_call_finish_handlers(ts_coroutine_t *ts, const bool is_bailout)
+{
+ ts_finish_handlers_vector_t *vector = ts->finish_handlers;
+
+ if (vector == NULL || vector->length == 0) {
+ return;
+ }
+
+ vector->in_execution = true;
+
+ uint32_t write_index = 0;
+
+ for (uint32_t read_index = 0; read_index < vector->length; read_index++) {
+ const ts_finish_handler_t *entry = &vector->data[read_index];
+
+ if (entry->handler(&ts->coro, entry->waiter, entry->data, is_bailout)) {
+ vector->data[write_index++] = vector->data[read_index];
+ }
+ }
+
+ vector->length = write_index;
+ vector->in_execution = false;
+}
+
+/* Slot functions for the core API: (coroutine, handler) -> handle. */
+static uint32_t ts_add_switch_handler(zend_coroutine_t *coroutine, zend_coroutine_switch_handler_fn handler)
+{
+ return ts_handlers_add(&ts_from_coro(coroutine)->switch_handlers, (ts_handler_fn) handler, "switch");
+}
+
+static bool ts_remove_switch_handler(zend_coroutine_t *coroutine, uint32_t handler_id)
+{
+ return ts_handlers_remove(ts_from_coro(coroutine)->switch_handlers, handler_id, "switch");
+}
+
+static uint32_t ts_add_finish_handler(zend_coroutine_t *coroutine, zend_coroutine_finish_handler_fn handler,
+ zend_coroutine_t *waiter, void *data)
+{
+ ts_coroutine_t *ts = ts_from_coro(coroutine);
+
+ if (ts->finish_handlers == NULL) {
+ ts->finish_handlers = ecalloc(1, sizeof(ts_finish_handlers_vector_t));
+ }
+
+ ts_finish_handlers_vector_t *vector = ts->finish_handlers;
+
+ if (UNEXPECTED(vector->in_execution)) {
+ zend_error(E_WARNING, "Cannot add a finish handler while handlers are running");
+ return 0;
+ }
+
+ for (uint32_t i = 0; i < vector->length; i++) {
+ if (vector->data[i].handler == handler && vector->data[i].waiter == waiter
+ && vector->data[i].data == data) {
+ return i + 1;
+ }
+ }
+
+ if (vector->length == vector->capacity) {
+ vector->capacity = vector->capacity ? vector->capacity * 2 : 4;
+ vector->data = safe_erealloc(vector->data, vector->capacity, sizeof(ts_finish_handler_t), 0);
+ }
+
+ vector->data[vector->length] = (ts_finish_handler_t) { handler, waiter, data };
+ vector->length++;
+
+ /* 1-based, as in ts_handlers_add. */
+ return vector->length;
+}
+
+static bool ts_remove_finish_handler(zend_coroutine_t *coroutine, uint32_t handler_id)
+{
+ ts_finish_handlers_vector_t *vector = ts_from_coro(coroutine)->finish_handlers;
+
+ if (vector == NULL || handler_id == 0 || handler_id > vector->length) {
+ return false;
+ }
+
+ if (UNEXPECTED(vector->in_execution)) {
+ zend_error(E_WARNING, "Cannot remove a finish handler while handlers are running");
+ return false;
+ }
+
+ for (uint32_t i = handler_id - 1; i < vector->length - 1; i++) {
+ vector->data[i] = vector->data[i + 1];
+ }
+
+ vector->length--;
+
+ return true;
+}
+
+///////////////////////////////////////////////////////////////////
+/// Awaiting info
+///////////////////////////////////////////////////////////////////
+
+static uint32_t ts_add_awaiting_info(
+ zend_coroutine_t *coroutine, zend_coroutine_awaiting_info_fn handler, void *data)
+{
+ ts_coroutine_t *ts = ts_from_coro(coroutine);
+
+ if (ts->awaiting_info == NULL) {
+ ts->awaiting_info = ecalloc(1, sizeof(ts_awaiting_info_vector_t));
+ }
+
+ ts_awaiting_info_vector_t *vector = ts->awaiting_info;
+
+ if (UNEXPECTED(vector->in_execution)) {
+ zend_error(E_WARNING, "Cannot add awaiting info while it is being collected");
+ return 0;
+ }
+
+ for (uint32_t i = 0; i < vector->length; i++) {
+ if (vector->data[i].handler == handler && vector->data[i].data == data) {
+ return i + 1;
+ }
+ }
+
+ if (vector->length == vector->capacity) {
+ vector->capacity = vector->capacity ? vector->capacity * 2 : 4;
+ vector->data = safe_erealloc(vector->data, vector->capacity, sizeof(ts_awaiting_info_t), 0);
+ }
+
+ vector->data[vector->length] = (ts_awaiting_info_t) { handler, data };
+ vector->length++;
+
+ /* 1-based, as in ts_handlers_add. */
+ return vector->length;
+}
+
+static bool ts_remove_awaiting_info(zend_coroutine_t *coroutine, uint32_t handler_id)
+{
+ ts_awaiting_info_vector_t *vector = ts_from_coro(coroutine)->awaiting_info;
+
+ if (vector == NULL || handler_id == 0 || handler_id > vector->length) {
+ return false;
+ }
+
+ if (UNEXPECTED(vector->in_execution)) {
+ zend_error(E_WARNING, "Cannot remove awaiting info while it is being collected");
+ return false;
+ }
+
+ for (uint32_t i = handler_id - 1; i < vector->length - 1; i++) {
+ vector->data[i] = vector->data[i + 1];
+ }
+
+ vector->length--;
+
+ return true;
+}
+
+/* The full cleanup: the wait is over, every description goes with it. */
+static void ts_awaiting_info_clear(ts_coroutine_t *ts)
+{
+ if (ts->awaiting_info != NULL) {
+ ts->awaiting_info->length = 0;
+ }
+}
+
+static zend_array *ts_get_awaiting_info(zend_coroutine_t *coroutine)
+{
+ ts_awaiting_info_vector_t *vector = ts_from_coro(coroutine)->awaiting_info;
+
+ if (vector == NULL || vector->length == 0) {
+ return NULL;
+ }
+
+ zend_array *info = zend_new_array(vector->length);
+
+ vector->in_execution = true;
+
+ for (uint32_t i = 0; i < vector->length; i++) {
+ const ts_awaiting_info_t *entry = &vector->data[i];
+ zend_string *description = entry->handler(coroutine, entry->data);
+
+ if (description != NULL) {
+ zval item;
+ ZVAL_STR(&item, description);
+ zend_hash_next_index_insert(info, &item);
+ }
+ }
+
+ vector->in_execution = false;
+
+ if (zend_hash_num_elements(info) == 0) {
+ zend_array_destroy(info);
+ return NULL;
+ }
+
+ return info;
+}
+
+///////////////////////////////////////////////////////////////////
+/// Awaiters
+///////////////////////////////////////////////////////////////////
+
+static void ts_waiters_add(ts_coroutine_t *target, ts_coroutine_t *waiter)
+{
+ for (uint32_t i = 0; i < target->waiters_length; i++) {
+ if (target->waiters[i] == waiter) {
+ return;
+ }
+ }
+
+ if (target->waiters_length == target->waiters_capacity) {
+ target->waiters_capacity = target->waiters_capacity ? target->waiters_capacity * 2 : 4;
+ target->waiters = safe_erealloc(
+ target->waiters, target->waiters_capacity, sizeof(ts_coroutine_t *), 0);
+ }
+
+ target->waiters[target->waiters_length++] = waiter;
+}
+
+static void ts_waiters_remove(ts_coroutine_t *target, const ts_coroutine_t *waiter)
+{
+ for (uint32_t i = 0; i < target->waiters_length; i++) {
+ if (target->waiters[i] == waiter) {
+ target->waiters[i] = target->waiters[--target->waiters_length];
+ return;
+ }
+ }
+}
+
+/* The awaiting-info handler ts_await() registers on the waiter: `data` is
+ * the awaited coroutine (alive for the whole wait — ts_await() holds a
+ * reference). */
+static zend_string *ts_await_awaiting_info(zend_coroutine_t *coroutine, void *data)
+{
+ (void) coroutine;
+
+ return zend_strpprintf(0, "await: coroutine #%u", ((ts_coroutine_t *) data)->std.handle);
+}
+
+/* Park the current coroutine until `coroutine` finishes. The wait holds its
+ * own reference: the last outside handle may die while we are parked. */
+static bool ts_await(zend_coroutine_t *coroutine)
+{
+ ts_coroutine_t *target = ts_from_coro(coroutine);
+ zend_coroutine_t *self = ZEND_ASYNC_CURRENT_COROUTINE;
+
+ if (UNEXPECTED(self == NULL || ZEND_ASYNC_IN_SCHEDULER_CONTEXT)) {
+ zend_throw_error(NULL, "await() requires a running coroutine");
+ return false;
+ }
+
+ if (UNEXPECTED(self == coroutine)) {
+ zend_throw_error(NULL, "Cannot await a coroutine from within itself");
+ return false;
+ }
+
+ ZEND_COROUTINE_ADD_REF(coroutine);
+
+ /* A stray resume() can wake us early: park again until it is really over.
+ * The wake wiped the awaiting info, so each lap registers it anew. */
+ while (!ZEND_COROUTINE_IS_FINISHED(coroutine)) {
+ ts_waiters_add(target, ts_from_coro(self));
+ ts_add_awaiting_info(self, ts_await_awaiting_info, target);
+
+ if (!ZEND_ASYNC_SUSPEND()) {
+ /* Cancelled while waiting: the outcome is no longer ours. */
+ ts_waiters_remove(target, ts_from_coro(self));
+ ZEND_COROUTINE_RELEASE(coroutine);
+ return false;
+ }
+ }
+
+ ZEND_COROUTINE_RELEASE(coroutine);
+
+ return true;
+}
+
+///////////////////////////////////////////////////////////////////
+/// Queue
+///////////////////////////////////////////////////////////////////
+
+static void ts_fifo_push(ts_fifo_t *fifo, ts_coroutine_t *ts)
+{
+ if (fifo->count == fifo->capacity) {
+ const size_t capacity = fifo->capacity ? fifo->capacity * 2 : 8;
+ ts_coroutine_t **data = safe_emalloc(capacity, sizeof(ts_coroutine_t *), 0);
+
+ for (size_t i = 0; i < fifo->count; i++) {
+ data[i] = fifo->data[(fifo->head + i) % fifo->capacity];
+ }
+
+ if (fifo->data != NULL) {
+ efree(fifo->data);
+ }
+
+ fifo->data = data;
+ fifo->head = 0;
+ fifo->capacity = capacity;
+ }
+
+ fifo->data[(fifo->head + fifo->count) % fifo->capacity] = ts;
+ fifo->count++;
+}
+
+static ts_coroutine_t *ts_fifo_shift(ts_fifo_t *fifo)
+{
+ if (fifo->count == 0) {
+ return NULL;
+ }
+
+ ts_coroutine_t *ts = fifo->data[fifo->head];
+ fifo->head = (fifo->head + 1) % fifo->capacity;
+ fifo->count--;
+
+ return ts;
+}
+
+static void ts_microtask_fifo_push(ts_microtask_fifo_t *fifo, zend_async_microtask_t *task)
+{
+ if (fifo->count == fifo->capacity) {
+ const size_t capacity = fifo->capacity ? fifo->capacity * 2 : 8;
+ zend_async_microtask_t **data = safe_emalloc(capacity, sizeof(zend_async_microtask_t *), 0);
+
+ for (size_t i = 0; i < fifo->count; i++) {
+ data[i] = fifo->data[(fifo->head + i) % fifo->capacity];
+ }
+
+ if (fifo->data != NULL) {
+ efree(fifo->data);
+ }
+
+ fifo->data = data;
+ fifo->head = 0;
+ fifo->capacity = capacity;
+ }
+
+ fifo->data[(fifo->head + fifo->count) % fifo->capacity] = task;
+ fifo->count++;
+}
+
+static zend_async_microtask_t *ts_microtask_fifo_shift(ts_microtask_fifo_t *fifo)
+{
+ if (fifo->count == 0) {
+ return NULL;
+ }
+
+ zend_async_microtask_t *task = fifo->data[fifo->head];
+ fifo->head = (fifo->head + 1) % fifo->capacity;
+ fifo->count--;
+
+ return task;
+}
+
+/* Run every microtask queued so far — the provider tick contract from
+ * zend_async_API.h. Called once per scheduler loop iteration, so a
+ * microtask that defers another one runs on the next tick, not this one. */
+static void ts_run_microtasks(void)
+{
+ zend_async_microtask_t *task;
+
+ while ((task = ts_microtask_fifo_shift(&TSG(microtasks))) != NULL) {
+ if (!ZEND_ASYNC_MICROTASK_IS_CANCELLED(task)) {
+ task->handler(task);
+ }
+
+ ZEND_ASYNC_MICROTASK_RELEASE(task);
+ }
+}
+
+static bool ts_defer(zend_async_microtask_t *task)
+{
+ ts_microtask_fifo_push(&TSG(microtasks), task);
+
+ return true;
+}
+
+///////////////////////////////////////////////////////////////////
+/// The coroutine object
+///////////////////////////////////////////////////////////////////
+
+static zend_object *ts_coroutine_object_create(zend_class_entry *ce)
+{
+ /* Plain emalloc, not zend_object_alloc(): a 0-property internal class
+ * (no ZEND_ACC_USE_GUARDS) makes zend_object_properties_size() underflow.
+ * Fiber's object_create() does the same for the same reason. */
+ ts_coroutine_t *ts = emalloc(sizeof(ts_coroutine_t));
+
+ memset(ts, 0, offsetof(ts_coroutine_t, std));
+
+ zend_object_std_init(&ts->std, ce);
+ object_properties_init(&ts->std, ce);
+ ts->std.handlers = &ts_coroutine_handlers;
+
+ ts->coro.object_offset = offsetof(ts_coroutine_t, std);
+ ZVAL_UNDEF(&ts->coro.result);
+ zend_async_internal_context_init(&ts->coro);
+
+ return &ts->std;
+}
+
+static void ts_coroutine_object_free(zend_object *object)
+{
+ ts_coroutine_t *ts = ts_from_obj(object);
+
+ /* Left only when a context was created but never entered: one that ran is
+ * destroyed by the engine, and the main context is a borrowed copy. */
+ if (ts->context_created && !ts->context_is_main
+ && ts->context.status != ZEND_FIBER_STATUS_DEAD) {
+ zend_fiber_destroy_context(&ts->context);
+ }
+
+ if (ts->vm_stack != NULL) {
+ zend_vm_stack current_stack = EG(vm_stack);
+
+ EG(vm_stack) = ts->vm_stack;
+ zend_vm_stack_destroy();
+ EG(vm_stack) = current_stack;
+ ts->vm_stack = NULL;
+ }
+
+ if (ts->pending_error != NULL) {
+ OBJ_RELEASE(ts->pending_error);
+ }
+
+ if (ts->coro.exception != NULL) {
+ OBJ_RELEASE(ts->coro.exception);
+ }
+
+ if (ts->coro.fcall != NULL) {
+ ZEND_ASYNC_FCALL_FREE(ts->coro.fcall);
+ ts->coro.fcall = NULL;
+ }
+
+ /* Whoever attached itself to this coroutine (a fiber, say) gets to let go
+ * of it now. */
+ if (ts->coro.extended_dispose != NULL) {
+ ts->coro.extended_dispose(&ts->coro);
+ }
+
+ zend_async_internal_context_destroy(&ts->coro);
+ zend_async_context_destroy(&ts->coro);
+
+ ts_handlers_free(ts->switch_handlers);
+
+ if (ts->finish_handlers != NULL) {
+ if (ts->finish_handlers->data != NULL) {
+ efree(ts->finish_handlers->data);
+ }
+ efree(ts->finish_handlers);
+ }
+
+ if (ts->awaiting_info != NULL) {
+ if (ts->awaiting_info->data != NULL) {
+ efree(ts->awaiting_info->data);
+ }
+ efree(ts->awaiting_info);
+ }
+
+ if (ts->waiters != NULL) {
+ efree(ts->waiters);
+ }
+
+ zval_ptr_dtor(&ts->coro.result);
+ zend_object_std_dtor(object);
+}
+
+static HashTable *ts_coroutine_object_gc(zend_object *object, zval **table, int *num)
+{
+ ts_coroutine_t *ts = ts_from_obj(object);
+ zend_get_gc_buffer *buf = zend_get_gc_buffer_create();
+
+ zend_get_gc_buffer_add_zval(buf, &ts->coro.result);
+
+ /* Owned object references: an exception's trace can point back at this
+ * very coroutine (await($self) in its arguments, say) — without these
+ * edges such a cycle never collects. */
+ if (ts->coro.exception != NULL) {
+ zend_get_gc_buffer_add_obj(buf, ts->coro.exception);
+ }
+
+ if (ts->pending_error != NULL) {
+ zend_get_gc_buffer_add_obj(buf, ts->pending_error);
+ }
+
+ if (ts->coro.fcall != NULL) {
+ zend_get_gc_buffer_add_zval(buf, &ts->coro.fcall->fci.function_name);
+
+ for (uint32_t i = 0; i < ts->coro.fcall->fci.param_count; i++) {
+ zend_get_gc_buffer_add_zval(buf, &ts->coro.fcall->fci.params[i]);
+ }
+
+ if (ts->coro.fcall->fci.named_params != NULL) {
+ zend_get_gc_buffer_add_ht(buf, ts->coro.fcall->fci.named_params);
+ }
+ }
+
+ /* The parked stack is deliberately NOT walked. References held by its
+ * frames (live temporaries included) are invisible to the collector, but
+ * that is safe by construction: trial deletion keeps anything whose
+ * refcount it cannot fully explain, so a frame-held object can never be
+ * collected out from under the coroutine — and the scheduler's table
+ * reference anchors the coroutine itself, so exposing frame edges cannot
+ * enable any collection either. */
+ zend_get_gc_buffer_use(buf, table, num);
+
+ return NULL;
+}
+
+static zend_coroutine_t *ts_coroutine_from_object(zend_object *object)
+{
+ if (object->ce != ts_ce_coroutine) {
+ return NULL;
+ }
+
+ return &ts_from_obj(object)->coro;
+}
+
+static ts_coroutine_t *ts_coroutine_new(void)
+{
+ zend_object *object = ts_coroutine_object_create(ts_ce_coroutine);
+ ts_coroutine_t *ts = ts_from_obj(object);
+
+ /* The scheduler owns every live coroutine (userland may drop the object at
+ * once, the body still runs): its birth reference belongs to the live table. */
+ zend_hash_index_add_ptr(&TSG(coroutines), object->handle, ts);
+
+ return ts;
+}
+
+/* Drop the scheduler's reference to a finished coroutine. Never from inside
+ * the coroutine itself — the object owns the fiber context, so freeing it
+ * there would destroy the stack underfoot. Retire it from the loop instead,
+ * once control is back on the scheduler's stack. */
+static void ts_coroutine_retire(ts_coroutine_t *ts)
+{
+ zend_hash_index_del(&TSG(coroutines), ts->std.handle);
+}
+
+///////////////////////////////////////////////////////////////////
+/// Fiber contexts
+///////////////////////////////////////////////////////////////////
+
+static ZEND_STACK_ALIGNED void ts_coroutine_entry(zend_fiber_transfer *transfer);
+static ZEND_STACK_ALIGNED void ts_scheduler_entry(zend_fiber_transfer *transfer);
+
+/* The scheduler loop follows the userland fiber.stack_size ini, but never
+ * below this floor — a script may shrink the ini to nothing, which must not
+ * starve the scheduler itself. */
+#define TS_SCHEDULER_MIN_STACK_SIZE (128 * 1024)
+
+static bool ts_context_create(ts_coroutine_t *ts, zend_fiber_coroutine entry, size_t stack_size)
+{
+ if (zend_fiber_init_context(&ts->context, ts_ce_coroutine, entry, stack_size)
+ == FAILURE) {
+ return false;
+ }
+
+ ts->context_created = true;
+
+ return true;
+}
+
+/* Install the VM stack a coroutine body runs on. Mirrors what the engine
+ * does for a fiber, with this scheduler's root frame at the bottom. */
+static zend_execute_data *ts_vm_stack_start(ts_coroutine_t *ts)
+{
+ zend_long error_reporting = zend_ini_long_literal("error_reporting");
+
+ /* NULL or an empty string means "never configured"; only "0" keeps 0. */
+ if (!error_reporting) {
+ zend_string *value = zend_ini_str_literal("error_reporting");
+
+ if (value == NULL || ZSTR_LEN(value) == 0) {
+ error_reporting = E_ALL;
+ }
+ }
+
+ zend_vm_stack stack = zend_vm_stack_new_page(ZEND_FIBER_VM_STACK_SIZE, NULL);
+
+ EG(vm_stack) = stack;
+ EG(vm_stack_top) = stack->top + ZEND_CALL_FRAME_SLOT;
+ EG(vm_stack_end) = stack->end;
+ EG(vm_stack_page_size) = ZEND_FIBER_VM_STACK_SIZE;
+
+ zend_execute_data *execute_data = (zend_execute_data *) stack->top;
+
+ memset(execute_data, 0, sizeof(zend_execute_data));
+ execute_data->func = &ts_root_function;
+
+ EG(current_execute_data) = execute_data;
+ EG(jit_trace_num) = 0;
+ EG(error_reporting) = (int) error_reporting;
+
+#ifdef ZEND_CHECK_STACK_LIMIT
+ EG(stack_base) = zend_fiber_stack_base(ts->context.stack);
+ EG(stack_limit) = zend_fiber_stack_limit(ts->context.stack);
+#endif
+
+ return execute_data;
+}
+
+static zend_object *ts_new_error(zend_class_entry *ce, const char *message)
+{
+ zval error;
+
+ object_init_ex(&error, ce);
+ zend_update_property_string(ce, Z_OBJ(error), ZEND_STRL("message"), message);
+
+ return Z_OBJ(error);
+}
+
+/* Fold into the exit exception: `exception` becomes the head, the previous one
+ * hangs off it as $previous. Takes ownership. */
+static void ts_exit_exception_fold(zend_object *exception)
+{
+ if (ZEND_ASYNC_EXIT_EXCEPTION != NULL) {
+ zend_exception_set_previous(exception, ZEND_ASYNC_EXIT_EXCEPTION);
+ }
+
+ ZEND_ASYNC_EXIT_EXCEPTION = exception;
+}
+
+/* A coroutine died of an exception nobody awaited: it ends the request. In the
+ * after-main drain it joins the exit exception, surfaced once the drain ends. */
+static void ts_report_unhandled(ts_coroutine_t *ts)
+{
+ zend_object *exception = ts->coro.exception;
+
+ ts->coro.exception = NULL;
+
+ if (ZEND_ASYNC_MAIN_COROUTINE == NULL) {
+ ts_exit_exception_fold(exception);
+ return;
+ }
+
+ zend_exception_error(exception, E_ERROR);
+}
+
+/* Switch into `target`, which the caller has taken out of the queue. The
+ * current-coroutine slot is written before the switch: that slot is how the
+ * target's entry point learns who it is. */
+static void ts_switch_into(ts_coroutine_t *target)
+{
+ zend_object *error = target->pending_error;
+ target->pending_error = NULL;
+
+ ZEND_COROUTINE_SET_STATUS(&target->coro, ZEND_COROUTINE_STATUS_RUNNING);
+ ZEND_ASYNC_CURRENT_COROUTINE = &target->coro;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false;
+
+ zend_fiber_transfer transfer = { .context = &target->context, .flags = 0 };
+
+ if (error != NULL) {
+ ZVAL_OBJ(&transfer.value, error);
+ transfer.flags = ZEND_FIBER_TRANSFER_FLAG_ERROR;
+ } else {
+ ZVAL_NULL(&transfer.value);
+ }
+
+ zend_fiber_switch_context(&transfer);
+
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true;
+ ZEND_ASYNC_CURRENT_COROUTINE = &TSG(scheduler)->coro;
+
+ zval_ptr_dtor(&transfer.value);
+
+ if (UNEXPECTED(transfer.flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT)) {
+ zend_bailout();
+ }
+
+ /* The context is dead and the engine has already destroyed it: now, on
+ * our own stack, it is safe to let the object go. */
+ if (ZEND_COROUTINE_IS_FINISHED(&target->coro)) {
+ /* While the main flow lives, an exception is "unhandled" only when
+ * nobody could ever look at it: no awaiter took it and no reference
+ * to the coroutine object remains (the live table holds the last
+ * one). A held object still offers await()/getException(). In the
+ * after-main drain nobody will come anymore — report everything. */
+ if (target->coro.exception != NULL && !target->exception_observed
+ && !instanceof_function(target->coro.exception->ce, ts_ce_cancellation_error)
+ && (ZEND_ASYNC_MAIN_COROUTINE == NULL || GC_REFCOUNT(&target->std) == 1)) {
+ ts_report_unhandled(target);
+ }
+
+ ts_coroutine_retire(target);
+ }
+}
+
+///////////////////////////////////////////////////////////////////
+/// Entry points
+///////////////////////////////////////////////////////////////////
+
+/* The first entry into a coroutine's context: build a VM stack, run the
+ * body, record the outcome, hand control back to the scheduler. */
+static ZEND_STACK_ALIGNED void ts_coroutine_entry(zend_fiber_transfer *transfer)
+{
+ ts_coroutine_t *ts = ts_from_coro(ZEND_ASYNC_CURRENT_COROUTINE);
+ bool bailout = false;
+
+ ZEND_ASSERT(ts != NULL && "A coroutine must be current when its context starts");
+
+ if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT)) {
+ /* Unwound by ts_bailout_all() before the body ever ran: skip it. */
+ bailout = true;
+ zval_ptr_dtor(&transfer->value);
+ ZVAL_UNDEF(&transfer->value);
+ } else if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) {
+ /* Cancelled before the body ever ran. */
+ ts->coro.exception = Z_OBJ(transfer->value);
+ ZVAL_UNDEF(&transfer->value);
+ } else {
+ zval_ptr_dtor(&transfer->value);
+ ZVAL_UNDEF(&transfer->value);
+
+ EG(vm_stack) = NULL;
+
+ zend_first_try {
+ ts_vm_stack_start(ts);
+
+ if (ts->coro.internal_entry != NULL) {
+ ts->coro.internal_entry();
+ } else {
+ ts->coro.fcall->fci.retval = &ts->coro.result;
+ zend_call_function(&ts->coro.fcall->fci, &ts->coro.fcall->fci_cache);
+ }
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ /* An unwind or a graceful exit is how a coroutine is told to
+ * stop, not something the body produced: it ends here. */
+ if (!zend_is_unwind_exit(EG(exception)) && !zend_is_graceful_exit(EG(exception))) {
+ ts->coro.exception = EG(exception);
+ GC_ADDREF(ts->coro.exception);
+ }
+
+ zend_clear_exception();
+ }
+ } zend_catch {
+ bailout = true;
+ } zend_end_try();
+
+ ts->vm_stack = EG(vm_stack);
+ }
+
+ ZEND_COROUTINE_SET_STATUS(&ts->coro, ZEND_COROUTINE_STATUS_FINISHED);
+
+ /* Whoever is watching this coroutine finds out it is gone for good —
+ * distinct from the switch-handler LEAVE call, which fires for an
+ * ordinary suspend that is coming back. */
+ ts_coroutine_call_finish_handlers(ts, bailout);
+
+ /* Everybody parked in await() runs again; the exception, if any, is
+ * theirs to rethrow rather than an unhandled one. On a bailout the queue
+ * is dead — ts_bailout_all() unwinds the waiters itself. */
+ if (!bailout && ts->waiters_length > 0) {
+ ts->exception_observed = true;
+
+ for (uint32_t i = 0; i < ts->waiters_length; i++) {
+ ZEND_ASYNC_ENQUEUE_COROUTINE(&ts->waiters[i]->coro);
+ }
+
+ ts->waiters_length = 0;
+ }
+
+ ZEND_ASYNC_CURRENT_COROUTINE = &TSG(scheduler)->coro;
+
+ transfer->context = &TSG(scheduler)->context;
+ transfer->flags = bailout ? ZEND_FIBER_TRANSFER_FLAG_BAILOUT : 0;
+ ZVAL_NULL(&transfer->value);
+}
+
+/* A bailout tears the request down; a coroutine parked on its own stack won't
+ * unwind by itself. Switch into each once with the bailout flag so its
+ * zend_first_try catches and unwinds, then control returns here. */
+static void ts_bailout_all(void)
+{
+ for (;;) {
+ ts_coroutine_t *ts = NULL;
+ zval *item;
+
+ ZEND_HASH_FOREACH_VAL(&TSG(coroutines), item) {
+ ts_coroutine_t *candidate = Z_PTR_P(item);
+
+ if (candidate->context_created && !candidate->context_is_main
+ && ZEND_COROUTINE_IS_STARTED(&candidate->coro)
+ && !ZEND_COROUTINE_IS_FINISHED(&candidate->coro)) {
+ ts = candidate;
+ break;
+ }
+ }
+ ZEND_HASH_FOREACH_END();
+
+ if (ts == NULL) {
+ /* Never-started coroutines end here: their finish handlers still
+ * fire their one time. */
+ ZEND_HASH_FOREACH_VAL(&TSG(coroutines), item) {
+ ts_coroutine_t *candidate = Z_PTR_P(item);
+
+ if (!ZEND_COROUTINE_IS_STARTED(&candidate->coro)
+ && !ZEND_COROUTINE_IS_FINISHED(&candidate->coro)) {
+ ZEND_COROUTINE_SET_STATUS(&candidate->coro, ZEND_COROUTINE_STATUS_FINISHED);
+ ts_coroutine_call_finish_handlers(candidate, /* is_bailout */ true);
+ }
+ }
+ ZEND_HASH_FOREACH_END();
+
+ return;
+ }
+
+ ZEND_COROUTINE_SET_STATUS(&ts->coro, ZEND_COROUTINE_STATUS_RUNNING);
+ ZEND_ASYNC_CURRENT_COROUTINE = &ts->coro;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false;
+
+ zend_fiber_transfer transfer = { .context = &ts->context,
+ .flags = ZEND_FIBER_TRANSFER_FLAG_BAILOUT };
+ ZVAL_NULL(&transfer.value);
+
+ zend_fiber_switch_context(&transfer);
+
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true;
+ ZEND_ASYNC_CURRENT_COROUTINE = &TSG(scheduler)->coro;
+
+ zval_ptr_dtor(&transfer.value);
+
+ /* The entry's tail has already run the finish handlers. */
+ ZEND_COROUTINE_SET_STATUS(&ts->coro, ZEND_COROUTINE_STATUS_FINISHED);
+ ts_coroutine_retire(ts);
+ }
+}
+
+/* The loop. It runs in a coroutine of its own, so handing control to the
+ * scheduler is an ordinary coroutine switch — the main flow needs no special
+ * case. Leaving this function ends the script: control goes back to the
+ * context the engine started on. */
+static ZEND_STACK_ALIGNED void ts_scheduler_entry(zend_fiber_transfer *transfer)
+{
+ ts_coroutine_t *self = TSG(scheduler);
+ bool bailout = (transfer->flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT) != 0;
+
+ EG(vm_stack) = NULL;
+
+ zend_first_try {
+ ts_vm_stack_start(self);
+
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true;
+
+ /* The main flow bailed out: there is nothing left to schedule, only
+ * stacks to unwind. */
+ if (UNEXPECTED(bailout)) {
+ ts_bailout_all();
+ goto done;
+ }
+
+ for (;;) {
+ ts_run_microtasks();
+
+ ts_coroutine_t *next = ts_fifo_shift(&TSG(queue));
+
+ if (next == NULL) {
+ if (zend_hash_num_elements(&TSG(coroutines)) == 0) {
+ break;
+ }
+
+ /* Nothing runnable, yet coroutines are still alive: they
+ * wait for something that will never happen, because this
+ * scheduler has no reactor to deliver it. */
+ zend_coroutine_t *main_coroutine = ZEND_ASYNC_MAIN_COROUTINE;
+
+ if (main_coroutine == NULL) {
+ /* No main to report a deadlock to: this is the after-main
+ * shutdown drain. Force-close whatever is still parked so it
+ * unwinds through its finally blocks while the scheduler is
+ * live — after this async deactivates and no later teardown
+ * could run it. */
+ bool cancelled_any = false;
+ zval *item;
+
+ ZEND_HASH_FOREACH_VAL(&TSG(coroutines), item) {
+ ts_coroutine_t *leftover = Z_PTR_P(item);
+
+ if (ZEND_COROUTINE_IS_STARTED(&leftover->coro)
+ && !ZEND_COROUTINE_IS_FINISHED(&leftover->coro)) {
+ ZEND_ASYNC_CANCEL(&leftover->coro, zend_create_graceful_exit(), true);
+ cancelled_any = true;
+ }
+ } ZEND_HASH_FOREACH_END();
+
+ if (!cancelled_any) {
+ break;
+ }
+
+ continue;
+ }
+
+ /* Terminal: DeadlockError becomes the exit exception; each
+ * parked coroutine gets a catchable cancellation. */
+ uint32_t waiting = 0;
+ zval *parked_item;
+
+ ZEND_HASH_FOREACH_VAL(&TSG(coroutines), parked_item) {
+ ts_coroutine_t *parked = Z_PTR_P(parked_item);
+
+ if (ZEND_COROUTINE_IS_SUSPENDED(&parked->coro)) {
+ waiting++;
+ }
+ }
+ ZEND_HASH_FOREACH_END();
+
+ char message[128];
+ snprintf(message, sizeof(message),
+ "Deadlock detected: no active coroutines, %u coroutines in waiting",
+ waiting);
+ ts_exit_exception_fold(ts_new_error(ts_ce_deadlock_error, message));
+
+ ZEND_HASH_FOREACH_VAL(&TSG(coroutines), parked_item) {
+ ts_coroutine_t *parked = Z_PTR_P(parked_item);
+
+ if (ZEND_COROUTINE_IS_SUSPENDED(&parked->coro)) {
+ ZEND_ASYNC_CANCEL(&parked->coro,
+ ts_new_error(ts_ce_cancellation_error, "Deadlock detected"),
+ true);
+ }
+ }
+ ZEND_HASH_FOREACH_END();
+
+ continue;
+ }
+
+ /* Keep EG(exception) clean before the graceful exit is delivered
+ * into `next`: thrown on top of a pending error it would chain onto
+ * the internal marker (bogus dynamic-property notice) and be lost.
+ * A cancellation is the scheduler's own doing — dropped. */
+ if (ZEND_ASYNC_MAIN_COROUTINE == NULL && EG(exception) != NULL) {
+ if (instanceof_function(EG(exception)->ce, ts_ce_cancellation_error)) {
+ OBJ_RELEASE(EG(exception));
+ } else {
+ ts_exit_exception_fold(EG(exception));
+ }
+ EG(exception) = NULL;
+ }
+
+ ts_switch_into(next);
+ }
+
+ /* Drain done: surface the collected chain once. Graceful-exit and
+ * cancellation remnants on EG are dropped in its favour. */
+ if (EG(exception) != NULL) {
+ if (zend_is_graceful_exit(EG(exception)) || zend_is_unwind_exit(EG(exception))
+ || instanceof_function(EG(exception)->ce, ts_ce_cancellation_error)) {
+ OBJ_RELEASE(EG(exception));
+ } else {
+ ts_exit_exception_fold(EG(exception));
+ }
+ EG(exception) = NULL;
+ }
+
+ if (ZEND_ASYNC_EXIT_EXCEPTION != NULL) {
+ zend_object *exception = ZEND_ASYNC_EXIT_EXCEPTION;
+ ZEND_ASYNC_EXIT_EXCEPTION = NULL;
+ zend_exception_error(exception, E_ERROR);
+ }
+
+done:;
+ } zend_catch {
+ /* A coroutine bailed out and the flag travelled here: unwind the rest
+ * before the request goes down. */
+ bailout = true;
+ ts_bailout_all();
+ } zend_end_try();
+
+ self->vm_stack = EG(vm_stack);
+ ZEND_COROUTINE_SET_STATUS(&self->coro, ZEND_COROUTINE_STATUS_FINISHED);
+
+ /* A bailout has to be re-raised on the stack that owns the request. If the
+ * main coroutine is still parked in a suspend, that is where it goes; once
+ * main is gone, the context the engine started on is the only one left. */
+ zend_coroutine_t *main_coroutine = ZEND_ASYNC_MAIN_COROUTINE;
+
+ if (bailout && main_coroutine != NULL && !ZEND_COROUTINE_IS_FINISHED(main_coroutine)) {
+ ZEND_ASYNC_CURRENT_COROUTINE = main_coroutine;
+ ZEND_COROUTINE_SET_STATUS(main_coroutine, ZEND_COROUTINE_STATUS_RUNNING);
+ transfer->context = &ts_from_coro(main_coroutine)->context;
+ } else {
+ ZEND_ASYNC_CURRENT_COROUTINE = NULL;
+ transfer->context = EG(main_fiber_context);
+ }
+
+ transfer->flags = bailout ? ZEND_FIBER_TRANSFER_FLAG_BAILOUT : 0;
+ ZVAL_NULL(&transfer->value);
+}
+
+///////////////////////////////////////////////////////////////////
+/// Launch: the script becomes a coroutine
+///////////////////////////////////////////////////////////////////
+
+/* Ensure a loop coroutine to switch into. It runs exactly once and its context
+ * dies with it, so a request needing another (destructors that spawn, the
+ * post-bailout drain) rebuilds it on demand. Not a task: it stays out of the
+ * live table, or it would count among the coroutines it waits for. */
+static bool ts_scheduler_ensure(void)
+{
+ ts_coroutine_t *scheduler = TSG(scheduler);
+
+ if (scheduler != NULL) {
+ if (scheduler->context.status != ZEND_FIBER_STATUS_DEAD
+ && !ZEND_COROUTINE_IS_FINISHED(&scheduler->coro)) {
+ return true;
+ }
+
+ TSG(scheduler) = NULL;
+ OBJ_RELEASE(&scheduler->std);
+ }
+
+ scheduler = ts_from_obj(ts_coroutine_object_create(ts_ce_coroutine));
+
+ size_t scheduler_stack_size = EG(fiber_stack_size) < TS_SCHEDULER_MIN_STACK_SIZE
+ ? TS_SCHEDULER_MIN_STACK_SIZE
+ : EG(fiber_stack_size);
+
+ if (!ts_context_create(scheduler, ts_scheduler_entry, scheduler_stack_size)) {
+ OBJ_RELEASE(&scheduler->std);
+ zend_throw_error(NULL, "Failed to create the scheduler's fiber context");
+ return false;
+ }
+
+ TSG(scheduler) = scheduler;
+
+ return true;
+}
+
+/* Wrap the engine's top-level context (EG(main_fiber_context), the OS thread
+ * stack) in a coroutine. A copy, so the coroutine owns a switchable handle
+ * while the engine's original stays the context execution lands back on.
+ * Called at first launch and again from ts_main_suspend() for shutdown. */
+static ts_coroutine_t *ts_adopt_main_context(void)
+{
+ ts_coroutine_t *main_coro = ts_coroutine_new();
+
+ zend_fiber_context *zero_context = EG(main_fiber_context);
+
+ main_coro->context = *zero_context;
+ main_coro->context_is_main = true;
+ main_coro->context_created = true;
+
+ EG(current_fiber_context) = &main_coro->context;
+
+ main_coro->context.status = ZEND_FIBER_STATUS_INIT;
+ zend_observer_fiber_switch_notify(zero_context, &main_coro->context);
+ main_coro->context.status = ZEND_FIBER_STATUS_RUNNING;
+
+ /* ts_main_suspend() calls this after the loop coroutine's run, which
+ * leaves this flag set; the adopted context is never the loop's own. */
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false;
+
+ return main_coro;
+}
+
+/* Called once per request. The main flow's identity is never relaunched here
+ * again — when index.php's coroutine finishes, ts_main_suspend() mints the
+ * replacement in place instead. */
+static zend_coroutine_t *ts_launch(void)
+{
+ ts_coroutine_t *main_coro = ts_adopt_main_context();
+
+ if (!ts_scheduler_ensure()) {
+ return NULL;
+ }
+
+ return &main_coro->coro;
+}
+
+///////////////////////////////////////////////////////////////////
+/// The Async Core slots
+///////////////////////////////////////////////////////////////////
+
+static zend_coroutine_t *ts_new_coroutine(size_t extra_size)
+{
+ (void) extra_size;
+
+ return &ts_coroutine_new()->coro;
+}
+
+/* This reference scheduler treats a GC coroutine exactly like any other:
+ * the FIFO run queue has no notion of priority to give it. A scheduler that
+ * does would tell them apart here. */
+static zend_coroutine_t *ts_gc_new_coroutine(void)
+{
+ return &ts_coroutine_new()->coro;
+}
+
+static bool ts_enqueue(zend_coroutine_t *coroutine, zend_object *error, bool transfer_error)
+{
+ ts_coroutine_t *ts = ts_from_coro(coroutine);
+
+ /* Finished: nothing to run, the enqueue is a no-op. */
+ if (UNEXPECTED(ZEND_COROUTINE_IS_FINISHED(coroutine))) {
+ if (error != NULL && transfer_error) {
+ OBJ_RELEASE(error);
+ }
+
+ return true;
+ }
+
+ /* Thrown at the suspension point when the coroutine runs. */
+ if (error != NULL) {
+ if (ts->pending_error != NULL) {
+ OBJ_RELEASE(ts->pending_error);
+ }
+
+ ts->pending_error = error;
+
+ if (!transfer_error) {
+ GC_ADDREF(error);
+ }
+ }
+
+ /* The script may be long over: a destructor cancelling a suspended fiber,
+ * or a fresh Fiber::start() from one, still needs a live loop. Only the
+ * loop is rebuilt on demand (the main identity never is — see ts_launch()). */
+ if (UNEXPECTED(!ts_scheduler_ensure())) {
+ return false;
+ }
+
+ if (ZEND_COROUTINE_IS_QUEUED(coroutine)) {
+ return true;
+ }
+
+ if (!ts->context_created && !ts_context_create(ts, ts_coroutine_entry, EG(fiber_stack_size))) {
+ /* zend_fiber_init_context() already threw (e.g. a stack too small):
+ * don't bury that with a second error the caller can't catch. */
+ if (!EG(exception)) {
+ zend_throw_error(NULL, "Failed to create a fiber context for the coroutine");
+ }
+ return false;
+ }
+
+ /* Runnable again: every wait description is stale — drop them all. */
+ ts_awaiting_info_clear(ts);
+
+ ZEND_COROUTINE_SET_STATUS(coroutine, ZEND_COROUTINE_STATUS_QUEUED);
+ ts_fifo_push(&TSG(queue), ts);
+
+ return true;
+}
+
+static zend_execute_data *ts_coroutine_execute_data(zend_coroutine_t *coroutine)
+{
+ ts_coroutine_t *ts = ts_from_coro(coroutine);
+
+ return ZEND_COROUTINE_IS_SUSPENDED(coroutine) ? ts->execute_data : NULL;
+}
+
+/* Cancellation is a resume with an error: the coroutine wakes inside the
+ * suspend it is parked in, the error is thrown there, and the body unwinds
+ * through its own finally blocks. */
+static bool ts_cancel(
+ zend_coroutine_t *coroutine, zend_object *error, bool transfer_error, const bool is_safely)
+{
+ (void) is_safely;
+
+ /* Nothing to unwind, or a cancellation is already in flight. The last case
+ * matters most: a fiber destroyed from inside its own force-close re-enters
+ * here for the coroutine it is already unwinding — re-enqueuing it leaves a
+ * stale entry that the loop later switches into after the context is gone.
+ * Cancellation is idempotent: the first graceful exit wins. */
+ if (ZEND_COROUTINE_IS_FINISHED(coroutine) || !ZEND_COROUTINE_IS_STARTED(coroutine)
+ || ZEND_COROUTINE_IS_CANCELLED(coroutine)) {
+ if (error != NULL && transfer_error) {
+ OBJ_RELEASE(error);
+ }
+
+ return true;
+ }
+
+ ZEND_COROUTINE_SET_CANCELLED(coroutine);
+
+ return ts_enqueue(coroutine, error, transfer_error);
+}
+
+/* index.php is over; main hands off to the loop, which drains and returns
+ * here. The old main is then replaced in place (a fresh one re-adopts
+ * EG(main_fiber_context)) rather than reset to READY for a lazy relaunch:
+ * this late, ts_launch() would copy a mid-unwind context (crash: "Invalid
+ * fiber context"). Async stays ACTIVE until the single ZEND_ASYNC_DEACTIVATE
+ * in php_request_shutdown(). */
+static bool ts_main_suspend(bool is_bailout)
+{
+ ts_coroutine_t *main_coro = ts_from_coro(ZEND_ASYNC_MAIN_COROUTINE);
+
+ /* The loop may already have run to completion (a bailout it handed to
+ * main, say): what is left to drain needs a live one. */
+ if (!ts_scheduler_ensure()) {
+ return false;
+ }
+
+ ts_coroutine_t *scheduler = TSG(scheduler);
+
+ ZEND_COROUTINE_SET_STATUS(&main_coro->coro, ZEND_COROUTINE_STATUS_FINISHED);
+ ts_coroutine_call_finish_handlers(main_coro, is_bailout);
+
+ /* Main finishes here, not in ts_coroutine_entry: wake its awaiters too. */
+ if (!is_bailout && main_coro->waiters_length > 0) {
+ for (uint32_t i = 0; i < main_coro->waiters_length; i++) {
+ ZEND_ASYNC_ENQUEUE_COROUTINE(&main_coro->waiters[i]->coro);
+ }
+
+ main_coro->waiters_length = 0;
+ }
+
+ ZEND_ASYNC_MAIN_COROUTINE = NULL;
+ ts_coroutine_retire(main_coro);
+
+ /* Back on the context the engine owns: the copy the main coroutine ran
+ * on describes the same stack, and it dies with the object. */
+ EG(current_fiber_context) = EG(main_fiber_context);
+ ZEND_ASYNC_CURRENT_COROUTINE = &scheduler->coro;
+
+ zend_fiber_transfer transfer = { .context = &scheduler->context,
+ .flags = is_bailout ? ZEND_FIBER_TRANSFER_FLAG_BAILOUT : 0 };
+ ZVAL_NULL(&transfer.value);
+
+ zend_fiber_switch_context(&transfer);
+
+ /* The loop is done and has handed control back to EG(main_fiber_context)
+ * — this stack, right here. Its own coroutine is spent either way. */
+ TSG(scheduler) = NULL;
+ OBJ_RELEASE(&scheduler->std);
+
+ /* A live main coroutine is mandatory past this point: mint the replacement
+ * before anything else (the bailout re-raise below) runs without one. */
+ ts_coroutine_t *shutdown_coro = ts_adopt_main_context();
+ ZEND_COROUTINE_SET_MAIN(&shutdown_coro->coro);
+ ZEND_COROUTINE_SET_STATUS(&shutdown_coro->coro, ZEND_COROUTINE_STATUS_RUNNING);
+ ZEND_ASYNC_MAIN_COROUTINE = &shutdown_coro->coro;
+ ZEND_ASYNC_CURRENT_COROUTINE = &shutdown_coro->coro;
+
+ const bool bailout = (transfer.flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT) != 0;
+
+ zval_ptr_dtor(&transfer.value);
+
+ if (UNEXPECTED(bailout)) {
+ zend_bailout();
+ }
+
+ return EG(exception) == NULL;
+}
+
+static bool ts_suspend(bool from_main, bool is_bailout)
+{
+ if (from_main) {
+ return ts_main_suspend(is_bailout);
+ }
+
+ ts_coroutine_t *self = ts_from_coro(ZEND_ASYNC_CURRENT_COROUTINE);
+
+ if (UNEXPECTED(self == NULL)) {
+ zend_throw_error(NULL, "There is no coroutine to suspend");
+ return false;
+ }
+
+ if (UNEXPECTED(ZEND_ASYNC_IN_SCHEDULER_CONTEXT)) {
+ zend_throw_error(NULL, "A coroutine cannot be suspended from the scheduler context");
+ return false;
+ }
+
+ /* Cancelled: never park again, nothing will wake it. */
+ if (UNEXPECTED(ZEND_COROUTINE_IS_CANCELLED(&self->coro))) {
+ zend_throw_exception(ts_ce_cancellation_error, "The coroutine has been cancelled", 0);
+ return false;
+ }
+
+ ZEND_COROUTINE_SET_STATUS(&self->coro, ZEND_COROUTINE_STATUS_SUSPENDED);
+ self->execute_data = EG(current_execute_data);
+ ZEND_ASYNC_CURRENT_COROUTINE = &TSG(scheduler)->coro;
+
+ /* Whoever is watching this coroutine (see zend_coroutine_switch_handler_fn)
+ * finds out right here, synchronously, that it is leaving — not on some
+ * later tick that may never come. */
+ ts_coroutine_call_switch_handlers(self, false);
+
+ zend_fiber_transfer transfer = { .context = &TSG(scheduler)->context, .flags = 0 };
+ ZVAL_NULL(&transfer.value);
+
+ zend_fiber_switch_context(&transfer);
+
+ /* The request is going down: re-raise the bailout on this stack so it
+ * unwinds like any other. */
+ if (UNEXPECTED(transfer.flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT)) {
+ zval_ptr_dtor(&transfer.value);
+ zend_bailout();
+ }
+
+ ts_coroutine_call_switch_handlers(self, true);
+
+ /* Resumed: the loop has recorded us as current again, and an error the
+ * resumer handed us is thrown at this exact point. */
+ if (UNEXPECTED(transfer.flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) {
+ zend_throw_exception_internal(Z_OBJ(transfer.value));
+ return false;
+ }
+
+ zval_ptr_dtor(&transfer.value);
+
+ return EG(exception) == NULL;
+}
+
+static bool ts_shutdown(void)
+{
+ php_printf("[scheduler] shutdown requested\n");
+
+ return true;
+}
+
+/* Every application fiber becomes a coroutine here — this scheduler keeps no
+ * fibers of its own out, so the answer is always yes. */
+static zend_coroutine_t *ts_intercept_fiber(zend_fiber *fiber)
+{
+ (void) fiber;
+
+ return &ts_coroutine_new()->coro;
+}
+
+static zend_class_entry *ts_get_class_ce(zend_async_class type)
+{
+ switch (type) {
+ case ZEND_ASYNC_CLASS_COROUTINE:
+ return ts_ce_coroutine;
+ case ZEND_ASYNC_EXCEPTION_CANCELLATION:
+ return ts_ce_cancellation_error;
+ default:
+ return zend_ce_exception;
+ }
+}
+
+///////////////////////////////////////////////////////////////////
+/// Userland API
+///////////////////////////////////////////////////////////////////
+
+PHP_FUNCTION(TestScheduler_spawn)
+{
+ zend_fcall_info fci;
+ zend_fcall_info_cache fcc;
+ uint32_t args_count = 0;
+ zval *args = NULL;
+ HashTable *named_args = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, -1)
+ Z_PARAM_FUNC(fci, fcc)
+ Z_PARAM_VARIADIC_WITH_NAMED(args, args_count, named_args)
+ ZEND_PARSE_PARAMETERS_END();
+
+ /* Without a live scheduler (the CLI's -r never launches one, and the
+ * request's final drain may be over) the coroutine would sit in the
+ * queue forever. Another provider's scheduler is not ours either. */
+ if (UNEXPECTED(!ts_registered || !ZEND_ASYNC_IS_ACTIVE)) {
+ zend_throw_error(NULL, "The scheduler is not running");
+ RETURN_THROWS();
+ }
+
+ ts_coroutine_t *ts = ts_coroutine_new();
+
+ ZEND_ASYNC_FCALL_DEFINE(fcall, fci, fcc, args, args_count, named_args);
+ ts->coro.fcall = fcall;
+
+ if (!ts_enqueue(&ts->coro, NULL, false)) {
+ ts_coroutine_retire(ts);
+ RETURN_THROWS();
+ }
+
+ RETURN_OBJ_COPY(&ts->std);
+}
+
+PHP_FUNCTION(TestScheduler_suspend)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ if (!ZEND_ASYNC_SUSPEND()) {
+ RETURN_THROWS();
+ }
+}
+
+PHP_FUNCTION(TestScheduler_resume)
+{
+ zend_object *object = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OF_CLASS(object, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ts_coroutine_t *ts = ts_from_obj(object);
+
+ if (!ZEND_COROUTINE_IS_SUSPENDED(&ts->coro)) {
+ zend_throw_error(NULL, "Cannot resume a coroutine that is not suspended");
+ RETURN_THROWS();
+ }
+
+ if (!ZEND_ASYNC_ENQUEUE_COROUTINE(&ts->coro)) {
+ RETURN_THROWS();
+ }
+}
+
+PHP_FUNCTION(TestScheduler_cancel)
+{
+ zend_object *object = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OF_CLASS(object, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ts_coroutine_t *ts = ts_from_obj(object);
+
+ /* Self-cancel: no suspend point to deliver into, throw in place. */
+ if (&ts->coro == ZEND_ASYNC_CURRENT_COROUTINE) {
+ ZEND_COROUTINE_SET_CANCELLED(&ts->coro);
+ zend_throw_exception(ts_ce_cancellation_error, "The coroutine has been cancelled", 0);
+ RETURN_THROWS();
+ }
+
+ if (!ZEND_ASYNC_CANCEL(&ts->coro,
+ ts_new_error(ts_ce_cancellation_error, "The coroutine has been cancelled"), true)) {
+ RETURN_THROWS();
+ }
+}
+
+PHP_FUNCTION(TestScheduler_await)
+{
+ zend_object *object = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OF_CLASS(object, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ ts_coroutine_t *target = ts_from_obj(object);
+
+ if (!ts_await(&target->coro)) {
+ RETURN_THROWS();
+ }
+
+ if (target->coro.exception != NULL) {
+ /* Still on the coroutine, so it was never reported as unhandled:
+ * every awaiter rethrows its own reference. */
+ target->exception_observed = true;
+
+ GC_ADDREF(target->coro.exception);
+ zend_throw_exception_internal(target->coro.exception);
+ RETURN_THROWS();
+ }
+
+ if (Z_ISUNDEF(target->coro.result)) {
+ RETURN_NULL();
+ }
+
+ RETURN_COPY(&target->coro.result);
+}
+
+PHP_FUNCTION(TestScheduler_current)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_coroutine_t *coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
+
+ if (coroutine == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_OBJ_COPY(ZEND_COROUTINE_OBJECT(coroutine));
+}
+
+PHP_METHOD(TestScheduler_Coroutine, __construct)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_throw_error(NULL, "A coroutine is created through TestScheduler\\spawn()");
+}
+
+#define TS_STATUS_METHOD(name, predicate) \
+ PHP_METHOD(TestScheduler_Coroutine, name) \
+ { \
+ ZEND_PARSE_PARAMETERS_NONE(); \
+ RETURN_BOOL(predicate(&ts_from_obj(Z_OBJ_P(ZEND_THIS))->coro)); \
+ }
+
+TS_STATUS_METHOD(isStarted, ZEND_COROUTINE_IS_STARTED)
+TS_STATUS_METHOD(isRunning, ZEND_COROUTINE_IS_RUNNING)
+TS_STATUS_METHOD(isSuspended, ZEND_COROUTINE_IS_SUSPENDED)
+TS_STATUS_METHOD(isFinished, ZEND_COROUTINE_IS_FINISHED)
+
+PHP_METHOD(TestScheduler_Coroutine, getResult)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ ts_coroutine_t *ts = ts_from_obj(Z_OBJ_P(ZEND_THIS));
+
+ if (Z_ISUNDEF(ts->coro.result)) {
+ RETURN_NULL();
+ }
+
+ RETURN_COPY(&ts->coro.result);
+}
+
+PHP_METHOD(TestScheduler_Coroutine, getException)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ ts_coroutine_t *ts = ts_from_obj(Z_OBJ_P(ZEND_THIS));
+
+ if (ts->coro.exception == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_OBJ_COPY(ts->coro.exception);
+}
+
+PHP_METHOD(TestScheduler_Coroutine, getAwaitingInfo)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_array *info = ZEND_ASYNC_GET_AWAITING_INFO(&ts_from_obj(Z_OBJ_P(ZEND_THIS))->coro);
+
+ if (info == NULL) {
+ RETURN_EMPTY_ARRAY();
+ }
+
+ RETURN_ARR(info);
+}
+
+///////////////////////////////////////////////////////////////////
+/// Module
+///////////////////////////////////////////////////////////////////
+
+static const zend_async_scheduler_api_t ts_scheduler_api = {
+ .size = sizeof(zend_async_scheduler_api_t),
+ .new_coroutine = ts_new_coroutine,
+ .gc_new_coroutine = ts_gc_new_coroutine,
+ .enqueue_coroutine = ts_enqueue,
+ .suspend = ts_suspend,
+ .launch = ts_launch,
+ .shutdown = ts_shutdown,
+ .cancel = ts_cancel,
+ .get_class_ce = ts_get_class_ce,
+ .coroutine_from_object = ts_coroutine_from_object,
+ .intercept_fiber = ts_intercept_fiber,
+ .coroutine_execute_data = ts_coroutine_execute_data,
+ .defer = ts_defer,
+ .add_switch_handler = ts_add_switch_handler,
+ .remove_switch_handler = ts_remove_switch_handler,
+ .add_finish_handler = ts_add_finish_handler,
+ .remove_finish_handler = ts_remove_finish_handler,
+ .await = ts_await,
+ .add_awaiting_info = ts_add_awaiting_info,
+ .remove_awaiting_info = ts_remove_awaiting_info,
+ .get_awaiting_info = ts_get_awaiting_info,
+};
+
+static PHP_GINIT_FUNCTION(test_scheduler)
+{
+#if defined(COMPILE_DL_TEST_SCHEDULER) && defined(ZTS)
+ ZEND_TSRMLS_CACHE_UPDATE();
+#endif
+ memset(test_scheduler_globals, 0, sizeof(*test_scheduler_globals));
+}
+
+static void ts_coroutine_table_dtor(zval *item)
+{
+ ts_coroutine_t *ts = Z_PTR_P(item);
+
+ OBJ_RELEASE(&ts->std);
+}
+
+PHP_MINIT_FUNCTION(test_scheduler)
+{
+ REGISTER_INI_ENTRIES();
+
+ if (!zend_ini_long(ZEND_STRL("test_scheduler.enable"), 0)) {
+ return SUCCESS;
+ }
+
+ ts_ce_coroutine = register_class_TestScheduler_Coroutine();
+ ts_ce_coroutine->create_object = ts_coroutine_object_create;
+
+ ts_ce_cancellation_error = register_class_TestScheduler_CancellationError(zend_ce_error);
+ ts_ce_deadlock_error = register_class_TestScheduler_DeadlockError(zend_ce_error);
+
+ memcpy(&ts_coroutine_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers));
+ ts_coroutine_handlers.offset = offsetof(ts_coroutine_t, std);
+ ts_coroutine_handlers.free_obj = ts_coroutine_object_free;
+ ts_coroutine_handlers.get_gc = ts_coroutine_object_gc;
+ ts_coroutine_handlers.clone_obj = NULL;
+
+ if (!zend_async_scheduler_register("test_scheduler", &ts_scheduler_api)) {
+ return FAILURE;
+ }
+
+ ts_registered = true;
+
+ return SUCCESS;
+}
+
+PHP_RINIT_FUNCTION(test_scheduler)
+{
+#if defined(ZTS) && defined(COMPILE_DL_TEST_SCHEDULER)
+ ZEND_TSRMLS_CACHE_UPDATE();
+#endif
+
+ if (!ts_registered) {
+ return SUCCESS;
+ }
+
+ zend_hash_init(&TSG(coroutines), 8, NULL, ts_coroutine_table_dtor, 0);
+
+ ZEND_ASYNC_INITIALIZE;
+
+ return SUCCESS;
+}
+
+PHP_RSHUTDOWN_FUNCTION(test_scheduler)
+{
+ if (!ts_registered) {
+ return SUCCESS;
+ }
+
+ if (TSG(queue).data != NULL) {
+ efree(TSG(queue).data);
+ TSG(queue).data = NULL;
+ }
+
+ TSG(queue).head = 0;
+ TSG(queue).count = 0;
+ TSG(queue).capacity = 0;
+
+ /* Anything still queued never got its tick: release it without running
+ * the handler, same as a cancelled task. */
+ zend_async_microtask_t *task;
+ while ((task = ts_microtask_fifo_shift(&TSG(microtasks))) != NULL) {
+ ZEND_ASYNC_MICROTASK_RELEASE(task);
+ }
+
+ if (TSG(microtasks).data != NULL) {
+ efree(TSG(microtasks).data);
+ TSG(microtasks).data = NULL;
+ }
+
+ TSG(microtasks).head = 0;
+ TSG(microtasks).count = 0;
+ TSG(microtasks).capacity = 0;
+
+ /* Only a bailout that cut the drain short can leave this set. */
+ if (ZEND_ASYNC_EXIT_EXCEPTION != NULL) {
+ OBJ_RELEASE(ZEND_ASYNC_EXIT_EXCEPTION);
+ ZEND_ASYNC_EXIT_EXCEPTION = NULL;
+ }
+
+ zend_hash_destroy(&TSG(coroutines));
+
+ return SUCCESS;
+}
+
+zend_module_entry test_scheduler_module_entry = {
+ STANDARD_MODULE_HEADER,
+ "test_scheduler",
+ ext_functions,
+ PHP_MINIT(test_scheduler),
+ NULL,
+ PHP_RINIT(test_scheduler),
+ PHP_RSHUTDOWN(test_scheduler),
+ NULL,
+ PHP_TEST_SCHEDULER_VERSION,
+ PHP_MODULE_GLOBALS(test_scheduler),
+ PHP_GINIT(test_scheduler),
+ NULL,
+ NULL,
+ STANDARD_MODULE_PROPERTIES_EX,
+};
+
+#ifdef COMPILE_DL_TEST_SCHEDULER
+# ifdef ZTS
+ZEND_TSRMLS_CACHE_DEFINE()
+# endif
+ZEND_GET_MODULE(test_scheduler)
+#endif
diff --git a/ext/test_scheduler/test_scheduler.stub.php b/ext/test_scheduler/test_scheduler.stub.php
new file mode 100644
index 000000000000..cd2fd4738d4a
--- /dev/null
+++ b/ext/test_scheduler/test_scheduler.stub.php
@@ -0,0 +1,74 @@
+
+ */
+ public function getAwaitingInfo(): array {}
+}
+
+/**
+ * Thrown into a cancelled coroutine at its suspend point. Catchable for
+ * cleanup, but a cancelled coroutine cannot suspend again.
+ */
+class CancellationError extends \Error
+{
+}
+
+/** Terminal: raised as the exit exception, catching it does not help. */
+final class DeadlockError extends \Error
+{
+}
+
+/** Queue a coroutine. The scheduler starts on the first call. */
+function spawn(callable $callback, mixed ...$args): Coroutine {}
+
+/** Yield the current flow (the main one included) to the scheduler. */
+function suspend(): void {}
+
+/** Put a suspended coroutine back into the run queue. */
+function resume(Coroutine $coroutine): void {}
+
+/** Throw a CancellationError at the coroutine's suspend point; repeats are no-ops. */
+function cancel(Coroutine $coroutine): void {}
+
+/**
+ * Park the current coroutine until $coroutine finishes; returns its result.
+ * An exception the coroutine finished with is rethrown here instead.
+ */
+function await(Coroutine $coroutine): mixed {}
+
+/** The coroutine running right now, or null outside one. */
+function current(): ?Coroutine {}
diff --git a/ext/test_scheduler/test_scheduler_arginfo.h b/ext/test_scheduler/test_scheduler_arginfo.h
new file mode 100644
index 000000000000..ed5a32cae89e
--- /dev/null
+++ b/ext/test_scheduler/test_scheduler_arginfo.h
@@ -0,0 +1,111 @@
+/* This is a generated file, edit test_scheduler.stub.php instead.
+ * Stub hash: 76721ca7326a23b884519baa2b408b3d19b3d0e7 */
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_TestScheduler_spawn, 0, 1, TestScheduler\\Coroutine, 0)
+ ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0)
+ ZEND_ARG_VARIADIC_TYPE_INFO(0, args, IS_MIXED, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_TestScheduler_suspend, 0, 0, IS_VOID, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_TestScheduler_resume, 0, 1, IS_VOID, 0)
+ ZEND_ARG_OBJ_INFO(0, coroutine, TestScheduler\\Coroutine, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_TestScheduler_cancel arginfo_TestScheduler_resume
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_TestScheduler_await, 0, 1, IS_MIXED, 0)
+ ZEND_ARG_OBJ_INFO(0, coroutine, TestScheduler\\Coroutine, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_TestScheduler_current, 0, 0, TestScheduler\\Coroutine, 1)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_INFO_EX(arginfo_class_TestScheduler_Coroutine___construct, 0, 0, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TestScheduler_Coroutine_isStarted, 0, 0, _IS_BOOL, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_TestScheduler_Coroutine_isRunning arginfo_class_TestScheduler_Coroutine_isStarted
+
+#define arginfo_class_TestScheduler_Coroutine_isSuspended arginfo_class_TestScheduler_Coroutine_isStarted
+
+#define arginfo_class_TestScheduler_Coroutine_isFinished arginfo_class_TestScheduler_Coroutine_isStarted
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TestScheduler_Coroutine_getResult, 0, 0, IS_MIXED, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_TestScheduler_Coroutine_getException, 0, 0, Throwable, 1)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_TestScheduler_Coroutine_getAwaitingInfo, 0, 0, IS_ARRAY, 0)
+ZEND_END_ARG_INFO()
+
+ZEND_FUNCTION(TestScheduler_spawn);
+ZEND_FUNCTION(TestScheduler_suspend);
+ZEND_FUNCTION(TestScheduler_resume);
+ZEND_FUNCTION(TestScheduler_cancel);
+ZEND_FUNCTION(TestScheduler_await);
+ZEND_FUNCTION(TestScheduler_current);
+ZEND_METHOD(TestScheduler_Coroutine, __construct);
+ZEND_METHOD(TestScheduler_Coroutine, isStarted);
+ZEND_METHOD(TestScheduler_Coroutine, isRunning);
+ZEND_METHOD(TestScheduler_Coroutine, isSuspended);
+ZEND_METHOD(TestScheduler_Coroutine, isFinished);
+ZEND_METHOD(TestScheduler_Coroutine, getResult);
+ZEND_METHOD(TestScheduler_Coroutine, getException);
+ZEND_METHOD(TestScheduler_Coroutine, getAwaitingInfo);
+
+static const zend_function_entry ext_functions[] = {
+ ZEND_RAW_FENTRY(ZEND_NS_NAME("TestScheduler", "spawn"), zif_TestScheduler_spawn, arginfo_TestScheduler_spawn, 0, NULL, NULL)
+ ZEND_RAW_FENTRY(ZEND_NS_NAME("TestScheduler", "suspend"), zif_TestScheduler_suspend, arginfo_TestScheduler_suspend, 0, NULL, NULL)
+ ZEND_RAW_FENTRY(ZEND_NS_NAME("TestScheduler", "resume"), zif_TestScheduler_resume, arginfo_TestScheduler_resume, 0, NULL, NULL)
+ ZEND_RAW_FENTRY(ZEND_NS_NAME("TestScheduler", "cancel"), zif_TestScheduler_cancel, arginfo_TestScheduler_cancel, 0, NULL, NULL)
+ ZEND_RAW_FENTRY(ZEND_NS_NAME("TestScheduler", "await"), zif_TestScheduler_await, arginfo_TestScheduler_await, 0, NULL, NULL)
+ ZEND_RAW_FENTRY(ZEND_NS_NAME("TestScheduler", "current"), zif_TestScheduler_current, arginfo_TestScheduler_current, 0, NULL, NULL)
+ ZEND_FE_END
+};
+
+static const zend_function_entry class_TestScheduler_Coroutine_methods[] = {
+ ZEND_ME(TestScheduler_Coroutine, __construct, arginfo_class_TestScheduler_Coroutine___construct, ZEND_ACC_PRIVATE)
+ ZEND_ME(TestScheduler_Coroutine, isStarted, arginfo_class_TestScheduler_Coroutine_isStarted, ZEND_ACC_PUBLIC)
+ ZEND_ME(TestScheduler_Coroutine, isRunning, arginfo_class_TestScheduler_Coroutine_isRunning, ZEND_ACC_PUBLIC)
+ ZEND_ME(TestScheduler_Coroutine, isSuspended, arginfo_class_TestScheduler_Coroutine_isSuspended, ZEND_ACC_PUBLIC)
+ ZEND_ME(TestScheduler_Coroutine, isFinished, arginfo_class_TestScheduler_Coroutine_isFinished, ZEND_ACC_PUBLIC)
+ ZEND_ME(TestScheduler_Coroutine, getResult, arginfo_class_TestScheduler_Coroutine_getResult, ZEND_ACC_PUBLIC)
+ ZEND_ME(TestScheduler_Coroutine, getException, arginfo_class_TestScheduler_Coroutine_getException, ZEND_ACC_PUBLIC)
+ ZEND_ME(TestScheduler_Coroutine, getAwaitingInfo, arginfo_class_TestScheduler_Coroutine_getAwaitingInfo, ZEND_ACC_PUBLIC)
+ ZEND_FE_END
+};
+
+static zend_class_entry *register_class_TestScheduler_Coroutine(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "TestScheduler", "Coroutine", class_TestScheduler_Coroutine_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE);
+
+ return class_entry;
+}
+
+static zend_class_entry *register_class_TestScheduler_CancellationError(zend_class_entry *class_entry_Error)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "TestScheduler", "CancellationError", NULL);
+ class_entry = zend_register_internal_class_with_flags(&ce, class_entry_Error, 0);
+
+ return class_entry;
+}
+
+static zend_class_entry *register_class_TestScheduler_DeadlockError(zend_class_entry *class_entry_Error)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "TestScheduler", "DeadlockError", NULL);
+ class_entry = zend_register_internal_class_with_flags(&ce, class_entry_Error, ZEND_ACC_FINAL);
+
+ return class_entry;
+}
diff --git a/ext/test_scheduler/tests/001_spawn_after_main.phpt b/ext/test_scheduler/tests/001_spawn_after_main.phpt
new file mode 100644
index 000000000000..48d27aa68801
--- /dev/null
+++ b/ext/test_scheduler/tests/001_spawn_after_main.phpt
@@ -0,0 +1,22 @@
+--TEST--
+test_scheduler: coroutines spawned by the script run when the script ends
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main: start
+main: end
+A
+B
diff --git a/ext/test_scheduler/tests/002_suspend_resume.phpt b/ext/test_scheduler/tests/002_suspend_resume.phpt
new file mode 100644
index 000000000000..8cf74d254dfc
--- /dev/null
+++ b/ext/test_scheduler/tests/002_suspend_resume.phpt
@@ -0,0 +1,27 @@
+--TEST--
+test_scheduler: the main flow suspends and a coroutine resumes it
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isFinished(), true), "\n";
+?>
+--EXPECT--
+main: suspending
+A: resuming main
+A: done
+main: resumed, A finished: true
diff --git a/ext/test_scheduler/tests/003_result_and_exception.phpt b/ext/test_scheduler/tests/003_result_and_exception.phpt
new file mode 100644
index 000000000000..fe6edcab9b4a
--- /dev/null
+++ b/ext/test_scheduler/tests/003_result_and_exception.phpt
@@ -0,0 +1,34 @@
+--TEST--
+test_scheduler: a coroutine records its result, and an exception nobody awaits is fatal
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ 42);
+spawn(function () use ($main, $ok) {
+ resume($main);
+});
+
+suspend();
+
+var_dump($ok->isFinished(), $ok->getResult());
+
+/* An exception that reaches the top of a coroutine ends the request, exactly
+ * as it does at the top of the script. */
+spawn(function () { throw new RuntimeException("boom"); });
+?>
+--EXPECTF--
+bool(true)
+int(42)
+
+Fatal error: Uncaught RuntimeException: boom in %s:%d
+Stack trace:
+#0 %s
+#1 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/004_deadlock.phpt b/ext/test_scheduler/tests/004_deadlock.phpt
new file mode 100644
index 000000000000..5b8926949f4c
--- /dev/null
+++ b/ext/test_scheduler/tests/004_deadlock.phpt
@@ -0,0 +1,21 @@
+--TEST--
+test_scheduler: suspending with nobody left to resume is a terminal deadlock
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECTF--
+before
+
+Fatal error: Uncaught TestScheduler\DeadlockError: Deadlock detected: no active coroutines, 1 coroutines in waiting in %s:%d
+Stack trace:
+#0 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/005_main_is_a_coroutine.phpt b/ext/test_scheduler/tests/005_main_is_a_coroutine.phpt
new file mode 100644
index 000000000000..57de4481656e
--- /dev/null
+++ b/ext/test_scheduler/tests/005_main_is_a_coroutine.phpt
@@ -0,0 +1,30 @@
+--TEST--
+test_scheduler: the top-level script is the main coroutine
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isRunning());
+
+spawn(function () use ($main) {
+ var_dump(current() !== $main);
+ var_dump($main->isSuspended());
+ resume($main);
+});
+
+suspend();
+var_dump($main->isRunning());
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/test_scheduler/tests/006_nested_spawn.phpt b/ext/test_scheduler/tests/006_nested_spawn.phpt
new file mode 100644
index 000000000000..94bed5cfdd2d
--- /dev/null
+++ b/ext/test_scheduler/tests/006_nested_spawn.phpt
@@ -0,0 +1,23 @@
+--TEST--
+test_scheduler: a coroutine spawns another one
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main: end
+outer: start
+outer: end
+inner
diff --git a/ext/test_scheduler/tests/007_generator_across_suspend.phpt b/ext/test_scheduler/tests/007_generator_across_suspend.phpt
new file mode 100644
index 000000000000..db8f44f46375
--- /dev/null
+++ b/ext/test_scheduler/tests/007_generator_across_suspend.phpt
@@ -0,0 +1,46 @@
+--TEST--
+test_scheduler: a generator advances across coroutine switches
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ "value-" . $i;
+ }
+}
+
+$consumer = null;
+
+$driver = spawn(function () use (&$consumer) {
+ suspend();
+ while (!$consumer->isFinished()) {
+ resume($consumer);
+ suspend();
+ }
+ echo "driver done\n";
+});
+
+$consumer = spawn(function () use ($driver) {
+ foreach (gen() as $key => $value) {
+ echo "$key => $value\n";
+ resume($driver);
+ suspend();
+ }
+ echo "consumer done\n";
+ resume($driver);
+});
+
+echo "main done\n";
+?>
+--EXPECT--
+main done
+key-0 => value-0
+key-1 => value-1
+key-2 => value-2
+consumer done
+driver done
diff --git a/ext/test_scheduler/tests/008_generator_running_guard.phpt b/ext/test_scheduler/tests/008_generator_running_guard.phpt
new file mode 100644
index 000000000000..4253d27b9652
--- /dev/null
+++ b/ext/test_scheduler/tests/008_generator_running_guard.phpt
@@ -0,0 +1,37 @@
+--TEST--
+test_scheduler: advancing a generator parked in another coroutine is an error
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+current(); // enters the body, parks
+ echo "not executed\n";
+});
+
+spawn(function () use ($g) {
+ try {
+ $g->next(); // the generator is mid-advance in another coroutine
+ } catch (Error $e) {
+ echo "Error: ", $e->getMessage(), "\n";
+ }
+});
+
+echo "main done\n";
+?>
+--EXPECT--
+main done
+gen enter
+Error: Cannot resume an already running generator
diff --git a/ext/test_scheduler/tests/009_generator_shutdown_unwind.phpt b/ext/test_scheduler/tests/009_generator_shutdown_unwind.phpt
new file mode 100644
index 000000000000..a7bd0a1c126f
--- /dev/null
+++ b/ext/test_scheduler/tests/009_generator_shutdown_unwind.phpt
@@ -0,0 +1,46 @@
+--TEST--
+test_scheduler: a yield-from chain parked in a coroutine unwinds cleanly at shutdown
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+current();
+ echo "not executed (coro)\n";
+ } finally {
+ echo "coro finally\n";
+ }
+});
+echo "==DONE==\n";
+?>
+--EXPECT--
+==DONE==
+inner enter
+inner finally
+outer finally
+coro finally
diff --git a/ext/test_scheduler/tests/010_generator_in_fiber_shutdown.phpt b/ext/test_scheduler/tests/010_generator_in_fiber_shutdown.phpt
new file mode 100644
index 000000000000..63be8da35e5f
--- /dev/null
+++ b/ext/test_scheduler/tests/010_generator_in_fiber_shutdown.phpt
@@ -0,0 +1,36 @@
+--TEST--
+test_scheduler: a generator parked at Fiber::suspend() unwinds with its fiber at shutdown
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+current(); // enters gen body, parks at Fiber::suspend()
+ echo "not executed (fiber)\n";
+ } finally {
+ echo "fiber finally\n";
+ }
+});
+$fiber->start();
+echo "==DONE==\n";
+?>
+--EXPECT--
+gen start
+==DONE==
+gen finally
+fiber finally
diff --git a/ext/test_scheduler/tests/011_unhandled_exception_surfaces.phpt b/ext/test_scheduler/tests/011_unhandled_exception_surfaces.phpt
new file mode 100644
index 000000000000..52778a9d86e6
--- /dev/null
+++ b/ext/test_scheduler/tests/011_unhandled_exception_surfaces.phpt
@@ -0,0 +1,31 @@
+--TEST--
+test_scheduler: an exception nobody awaited surfaces as a fatal error
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage(), "\n";
+ }
+});
+
+TestScheduler\spawn(function () {
+ throw new Exception("nobody awaits this");
+});
+
+echo "==DONE==\n";
+?>
+--EXPECTF--
+==DONE==
+caught: Cannot suspend outside of a fiber
+
+Fatal error: Uncaught Exception: nobody awaits this in %s011_unhandled_exception_surfaces.php:%d
+Stack trace:
+#0 [internal function]: {closure:%s:%d}()
+#1 {main}
+ thrown in %s011_unhandled_exception_surfaces.php on line %d
diff --git a/ext/test_scheduler/tests/012_await_basic.phpt b/ext/test_scheduler/tests/012_await_basic.phpt
new file mode 100644
index 000000000000..2835d0b24152
--- /dev/null
+++ b/ext/test_scheduler/tests/012_await_basic.phpt
@@ -0,0 +1,44 @@
+--TEST--
+test_scheduler: await() returns results, rethrows exceptions, works from generators
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ 42);
+$b = spawn(function () { throw new RuntimeException("boom"); });
+
+function gen($a): Generator {
+ yield await($a); // awaiting inside a generator body
+ yield 7;
+}
+
+spawn(function () use ($main, $a, $b) {
+ echo "await a: ", await($a), "\n";
+ try {
+ await($b);
+ } catch (RuntimeException $e) {
+ echo "await b threw: ", $e->getMessage(), "\n";
+ }
+ echo "late await a: ", await($a), "\n"; // already finished
+ foreach (gen($a) as $v) {
+ echo "gen: $v\n";
+ }
+ resume($main);
+});
+
+suspend();
+echo "main done\n";
+?>
+--EXPECT--
+await a: 42
+await b threw: boom
+late await a: 42
+gen: 42
+gen: 7
+main done
diff --git a/ext/test_scheduler/tests/013_await_multiple_waiters.phpt b/ext/test_scheduler/tests/013_await_multiple_waiters.phpt
new file mode 100644
index 000000000000..e20177ebd11f
--- /dev/null
+++ b/ext/test_scheduler/tests/013_await_multiple_waiters.phpt
@@ -0,0 +1,30 @@
+--TEST--
+test_scheduler: several coroutines (the main one included) await the same target
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ "w1: " . await($target));
+$w2 = spawn(fn () => "w2: " . await($target));
+
+spawn(function () use ($target) {
+ resume($target);
+});
+
+echo "w1 => ", await($w1), "\n"; // main parks in await() too
+echo "w2 => ", await($w2), "\n";
+echo "==DONE==\n";
+?>
+--EXPECT--
+w1 => w1: shared
+w2 => w2: shared
+==DONE==
diff --git a/ext/test_scheduler/tests/014_await_errors.phpt b/ext/test_scheduler/tests/014_await_errors.phpt
new file mode 100644
index 000000000000..51045e959f95
--- /dev/null
+++ b/ext/test_scheduler/tests/014_await_errors.phpt
@@ -0,0 +1,41 @@
+--TEST--
+test_scheduler: await() misuse — awaiting itself, awaited exception stays claimable
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage(), "\n";
+}
+
+// An exception someone holds a handle to is not "unhandled": every await
+// rethrows it, and nothing is reported at shutdown.
+$failing = spawn(function () { throw new LogicException("kept"); });
+try {
+ await($failing);
+} catch (LogicException $e) {
+ echo "first: ", $e->getMessage(), "\n";
+}
+try {
+ await($failing);
+} catch (LogicException $e) {
+ echo "second: ", $e->getMessage(), "\n";
+}
+echo "getException: ", $failing->getException()->getMessage(), "\n";
+echo "==DONE==\n";
+?>
+--EXPECT--
+self: Cannot await a coroutine from within itself
+first: kept
+second: kept
+getException: kept
+==DONE==
diff --git a/ext/test_scheduler/tests/015_deadlock_mutual_await.phpt b/ext/test_scheduler/tests/015_deadlock_mutual_await.phpt
new file mode 100644
index 000000000000..564bec6576fa
--- /dev/null
+++ b/ext/test_scheduler/tests/015_deadlock_mutual_await.phpt
@@ -0,0 +1,51 @@
+--TEST--
+test_scheduler: two coroutines awaiting each other deadlock the waiting main
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECTF--
+start
+c1 running
+c2 running
+c1 finally
+c2 finally
+
+Fatal error: Uncaught TestScheduler\DeadlockError: Deadlock detected: no active coroutines, 3 coroutines in waiting in %s:%d
+Stack trace:
+#0 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/016_deadlock_caught.phpt b/ext/test_scheduler/tests/016_deadlock_caught.phpt
new file mode 100644
index 000000000000..289328cf2111
--- /dev/null
+++ b/ext/test_scheduler/tests/016_deadlock_caught.phpt
@@ -0,0 +1,50 @@
+--TEST--
+test_scheduler: catching the cancellation does not suppress the terminal DeadlockError
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage(), "\n";
+}
+
+echo "main continues\n";
+?>
+--EXPECTF--
+caught: Deadlock detected
+main continues
+c1 finally
+c2 finally
+
+Fatal error: Uncaught TestScheduler\DeadlockError: Deadlock detected: no active coroutines, 3 coroutines in waiting in %s:%d
+Stack trace:
+#0 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/017_deadlock_after_main.phpt b/ext/test_scheduler/tests/017_deadlock_after_main.phpt
new file mode 100644
index 000000000000..e0ea539bd72e
--- /dev/null
+++ b/ext/test_scheduler/tests/017_deadlock_after_main.phpt
@@ -0,0 +1,43 @@
+--TEST--
+test_scheduler: a mutually-awaiting pair left behind by main is closed gracefully
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main done
+c1 running
+c2 running
+c1 finally
+c2 finally
diff --git a/ext/test_scheduler/tests/018_cancel.phpt b/ext/test_scheduler/tests/018_cancel.phpt
new file mode 100644
index 000000000000..52b7d6ad6689
--- /dev/null
+++ b/ext/test_scheduler/tests/018_cancel.phpt
@@ -0,0 +1,70 @@
+--TEST--
+test_scheduler: cancel() — catchable at the suspend point, no re-park, first cancel wins
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage(), "\n";
+ }
+
+ // Cancelled means done waiting: parking again is refused on the spot.
+ try {
+ suspend();
+ echo "unreachable suspend\n";
+ } catch (TestScheduler\CancellationError $e) {
+ echo "victim cannot suspend again\n";
+ }
+
+ return "cleanup done";
+});
+
+spawn(function () use ($victim) {
+ cancel($victim);
+ cancel($victim); // the second one is a no-op
+});
+
+echo "await victim: ", await($victim), "\n";
+
+// A queued coroutine cancelled before it ever ran: the body stays unrun and
+// the cancellation is its outcome.
+$unstarted = spawn(function () {
+ echo "unreachable body\n";
+});
+cancel($unstarted);
+try {
+ await($unstarted);
+} catch (TestScheduler\CancellationError $e) {
+ echo "unstarted: ", $e->getMessage(), "\n";
+}
+
+// Self-cancel throws in place.
+$self = spawn(function () {
+ try {
+ cancel(current());
+ echo "unreachable\n";
+ } catch (TestScheduler\CancellationError $e) {
+ echo "self-cancel caught\n";
+ }
+});
+await($self);
+
+echo "==DONE==\n";
+?>
+--EXPECT--
+await victim: victim caught: The coroutine has been cancelled
+victim cannot suspend again
+cleanup done
+unstarted: The coroutine has been cancelled
+self-cancel caught
+==DONE==
diff --git a/ext/test_scheduler/tests/019_await_main.phpt b/ext/test_scheduler/tests/019_await_main.phpt
new file mode 100644
index 000000000000..1ad5304e1dc3
--- /dev/null
+++ b/ext/test_scheduler/tests/019_await_main.phpt
@@ -0,0 +1,27 @@
+--TEST--
+test_scheduler: awaiting the main coroutine wakes up when the script ends
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main done
+main awaited
diff --git a/ext/test_scheduler/tests/020_gc_await_cancel.phpt b/ext/test_scheduler/tests/020_gc_await_cancel.phpt
new file mode 100644
index 000000000000..30c1052eb222
--- /dev/null
+++ b/ext/test_scheduler/tests/020_gc_await_cancel.phpt
@@ -0,0 +1,46 @@
+--TEST--
+test_scheduler: cancelling a coroutine parked in a GC run exits cleanly, no leaks
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $a;
+ unset($a);
+
+ try {
+ gc_collect_cycles();
+ echo "gc returned\n";
+ } catch (TestScheduler\CancellationError $e) {
+ echo "gcer cancelled\n";
+ }
+});
+
+spawn(function () use ($gcer, $main) {
+ cancel($gcer);
+ resume($main);
+});
+
+suspend();
+echo "main done\n";
+?>
+--EXPECT--
+gcer cancelled
+main done
+dtor suspends
diff --git a/ext/test_scheduler/tests/021_gc_deadlock.phpt b/ext/test_scheduler/tests/021_gc_deadlock.phpt
new file mode 100644
index 000000000000..d981d181ad24
--- /dev/null
+++ b/ext/test_scheduler/tests/021_gc_deadlock.phpt
@@ -0,0 +1,33 @@
+--TEST--
+test_scheduler: a destructor deadlocking inside a GC run terminates without crashing
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $a;
+unset($a);
+
+gc_collect_cycles();
+echo "after gc\n";
+?>
+--EXPECTF--
+dtor suspends
+
+Fatal error: Uncaught TestScheduler\DeadlockError: Deadlock detected: no active coroutines, 3 coroutines in waiting in %s:%d
+Stack trace:
+#0 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/022_fiber_status_await.phpt b/ext/test_scheduler/tests/022_fiber_status_await.phpt
new file mode 100644
index 000000000000..6eda0705b3da
--- /dev/null
+++ b/ext/test_scheduler/tests/022_fiber_status_await.phpt
@@ -0,0 +1,43 @@
+--TEST--
+test_scheduler: a fiber awaiting inside its body is running, not suspended
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isSuspended()); // await inside the body: not suspended
+ var_dump($fiber->isRunning()); // ... it is running, blocked internally
+ resume($gate);
+});
+
+$fiber = new Fiber(function () use ($gate, $obs) {
+ resume($obs);
+ await($gate);
+ Fiber::suspend("yielded");
+ return "end";
+});
+
+$fiber->start();
+
+// Now parked in Fiber::suspend() proper: the classic contract holds.
+var_dump($fiber->isSuspended());
+var_dump($fiber->isRunning());
+
+$fiber->resume();
+var_dump($fiber->isTerminated());
+echo "==DONE==\n";
+?>
+--EXPECT--
+bool(false)
+bool(true)
+bool(true)
+bool(false)
+bool(true)
+==DONE==
diff --git a/ext/test_scheduler/tests/023_gc_parked_frame_temps.phpt b/ext/test_scheduler/tests/023_gc_parked_frame_temps.phpt
new file mode 100644
index 000000000000..1c39a032487f
--- /dev/null
+++ b/ext/test_scheduler/tests/023_gc_parked_frame_temps.phpt
@@ -0,0 +1,47 @@
+--TEST--
+test_scheduler: a frame-held object of a parked coroutine survives GC, collects after use
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this; // a GC candidate
+ $weak = WeakReference::create($this);
+ }
+}
+
+function op(Cyc $c, $x) { return $c; }
+
+$main = current();
+
+$co = spawn(function () {
+ // While parked, the Cyc lives only in the in-flight argument slot.
+ $r = op(new Cyc(), suspend());
+ echo "resumed, self ", ($r->self === $r ? "intact" : "broken"), "\n";
+});
+
+spawn(function () use ($co, $main) {
+ gc_collect_cycles();
+ global $weak;
+ echo "after gc: ", ($weak->get() === null ? "collected" : "alive"), "\n";
+ resume($co);
+ resume($main);
+});
+
+suspend();
+gc_collect_cycles();
+echo "end: ", ($weak->get() === null ? "collected" : "alive"), "\n";
+?>
+--EXPECT--
+after gc: alive
+resumed, self intact
+end: collected
diff --git a/ext/test_scheduler/tests/024_gc_coroutine_weakref.phpt b/ext/test_scheduler/tests/024_gc_coroutine_weakref.phpt
new file mode 100644
index 000000000000..e7118458b41b
--- /dev/null
+++ b/ext/test_scheduler/tests/024_gc_coroutine_weakref.phpt
@@ -0,0 +1,95 @@
+--TEST--
+test_scheduler: coroutine lifetime edges observed via WeakReference
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+co = $co; } }
+class Carrier extends RuntimeException { public $co; }
+
+echo "-- 1: cycle via own closure, handle dropped while parked --\n";
+$co1 = null;
+$co1 = spawn(function () use (&$co1) { $self = $co1; suspend(); });
+$w1 = WeakReference::create($co1);
+$h1 = $co1;
+unset($co1);
+gc_collect_cycles(); // runs while co1 is parked: the scheduler anchors it
+echo "parked: ", ($w1->get() === null ? "collected" : "alive"), "\n";
+resume($h1);
+await($h1);
+unset($h1);
+gc_collect_cycles(); // co -> closure -> co collapses once finished
+echo "finished: ", ($w1->get() === null ? "collected" : "alive"), "\n";
+
+echo "-- 2: cycle via result --\n";
+$co2 = null;
+$co2 = spawn(function () use (&$co2) { return new Box($co2); });
+$w2 = WeakReference::create($co2);
+await($co2);
+unset($co2);
+gc_collect_cycles();
+echo "finished: ", ($w2->get() === null ? "collected" : "alive"), "\n";
+
+echo "-- 3: cycle via stored exception --\n";
+$co3 = null;
+$co3 = spawn(function () use (&$co3) {
+ $e = new Carrier("boom");
+ $e->co = $co3;
+ throw $e;
+});
+$w3 = WeakReference::create($co3);
+try { await($co3); } catch (Carrier $e) { echo "caught: ", $e->getMessage(), "\n"; unset($e); }
+unset($co3);
+gc_collect_cycles();
+echo "finished: ", ($w3->get() === null ? "collected" : "alive"), "\n";
+
+echo "-- 4: an awaiter keeps the coroutine alive --\n";
+$co4 = null;
+$co4 = spawn(function () use (&$co4) { $self = $co4; suspend(); return "ok"; });
+$w4 = WeakReference::create($co4);
+$checker = spawn(function () use ($co4) {
+ echo "await returns: ", await($co4), "\n";
+});
+$h4 = $co4;
+unset($co4);
+gc_collect_cycles();
+echo "awaited: ", ($w4->get() === null ? "collected" : "alive"), "\n";
+resume($h4); unset($h4);
+await($checker); unset($checker);
+gc_collect_cycles();
+echo "finished: ", ($w4->get() === null ? "collected" : "alive"), "\n";
+
+echo "-- 5: cancelled coroutine with a closure cycle --\n";
+$co5 = null;
+$co5 = spawn(function () use (&$co5) {
+ $self = $co5;
+ try { suspend(); } catch (TestScheduler\CancellationError $e) { echo "cancelled\n"; }
+});
+$w5 = WeakReference::create($co5);
+spawn(function () use ($co5) { cancel($co5); });
+$h5 = $co5; unset($co5);
+try { await($h5); } catch (TestScheduler\CancellationError $e) { echo "await threw\n"; }
+unset($h5);
+gc_collect_cycles();
+echo "finished: ", ($w5->get() === null ? "collected" : "alive"), "\n";
+?>
+--EXPECT--
+-- 1: cycle via own closure, handle dropped while parked --
+parked: alive
+finished: collected
+-- 2: cycle via result --
+finished: collected
+-- 3: cycle via stored exception --
+caught: boom
+finished: collected
+-- 4: an awaiter keeps the coroutine alive --
+await returns: awaited: alive
+ok
+finished: collected
+-- 5: cancelled coroutine with a closure cycle --
+cancelled
+finished: collected
diff --git a/ext/test_scheduler/tests/025_awaiting_info.phpt b/ext/test_scheduler/tests/025_awaiting_info.phpt
new file mode 100644
index 000000000000..bb4300294aac
--- /dev/null
+++ b/ext/test_scheduler/tests/025_awaiting_info.phpt
@@ -0,0 +1,84 @@
+--TEST--
+getAwaitingInfo(): await registers a description, enqueue wipes the whole vector
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getAwaitingInfo());
+
+$waiter = spawn(function () use ($target, $main) {
+ $result = await($target);
+ resume($main);
+ return $result;
+});
+
+spawn(function () use ($main, $target, $waiter) {
+ echo "-- waiter parked in await --\n";
+ var_dump($waiter->isSuspended());
+ var_dump($waiter->getAwaitingInfo());
+
+ /* A plain suspend() says nothing about itself. */
+ var_dump($target->getAwaitingInfo());
+
+ /* A stray resume() puts the waiter back into the queue: the enqueue
+ * wipes every description at once — the full cleanup. */
+ resume($waiter);
+ echo "-- queued by a stray resume: info wiped --\n";
+ var_dump($waiter->getAwaitingInfo());
+
+ resume($main);
+});
+
+suspend();
+
+/* The woken waiter re-parked in await and registered the wait again. */
+echo "-- waiter re-parked --\n";
+var_dump($waiter->getAwaitingInfo());
+
+/* The target finishes, the waiter consumes the result: nothing waits now. */
+resume($target);
+suspend();
+
+echo "-- both finished --\n";
+var_dump($waiter->isFinished());
+var_dump($waiter->getResult());
+var_dump($waiter->getAwaitingInfo());
+
+?>
+--EXPECTF--
+array(0) {
+}
+-- waiter parked in await --
+bool(true)
+array(1) {
+ [0]=>
+ string(%d) "await: coroutine #%d"
+}
+array(0) {
+}
+-- queued by a stray resume: info wiped --
+array(0) {
+}
+-- waiter re-parked --
+array(1) {
+ [0]=>
+ string(%d) "await: coroutine #%d"
+}
+-- both finished --
+bool(true)
+string(4) "done"
+array(0) {
+}
diff --git a/ext/test_scheduler/tests/026_dtor_fiber_suspend.phpt b/ext/test_scheduler/tests/026_dtor_fiber_suspend.phpt
new file mode 100644
index 000000000000..9ee0355f64f5
--- /dev/null
+++ b/ext/test_scheduler/tests/026_dtor_fiber_suspend.phpt
@@ -0,0 +1,61 @@
+--TEST--
+Fibers in destructors 001: Fiber::suspend() in a destructor throws — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+ public function __destruct() {
+ $id = self::$counter++;
+ printf("%d: Start destruct\n", $id);
+ if ($id === 0) {
+ try {
+ Fiber::suspend(new stdClass);
+ printf("%d: suspend returned\n", $id);
+ } catch (FiberError $e) {
+ printf("%d: %s: %s\n", $id, get_class($e), $e->getMessage());
+ }
+ }
+ printf("%d: End destruct\n", $id);
+ }
+}
+
+$f = new Fiber(function () {
+ new Cycle();
+ new Cycle();
+ new Cycle();
+ gc_collect_cycles();
+ printf("fiber: done\n");
+});
+
+$f->start();
+
+echo "==DONE==\n";
+?>
+--EXPECT--
+0: Start destruct
+0: FiberError: Cannot suspend outside of a fiber
+0: End destruct
+1: Start destruct
+1: End destruct
+2: Start destruct
+2: End destruct
+fiber: done
+==DONE==
+Shutdown
diff --git a/ext/test_scheduler/tests/027_dtor_fiber_start.phpt b/ext/test_scheduler/tests/027_dtor_fiber_start.phpt
new file mode 100644
index 000000000000..31b5b2a243bd
--- /dev/null
+++ b/ext/test_scheduler/tests/027_dtor_fiber_start.phpt
@@ -0,0 +1,39 @@
+--TEST--
+Fibers in destructors 002: Start in destructor — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+ public function __destruct() {
+ $id = self::$counter++;
+ printf("%d: Start destruct\n", $id);
+ $f = new Fiber(function () { });
+ $f->start();
+ printf("%d: End destruct\n", $id);
+ }
+}
+
+new Cycle();
+new Cycle();
+gc_collect_cycles();
+
+?>
+--EXPECT--
+0: Start destruct
+1: Start destruct
+0: End destruct
+1: End destruct
+Shutdown
diff --git a/ext/test_scheduler/tests/034_gc_inside_fiber.phpt b/ext/test_scheduler/tests/034_gc_inside_fiber.phpt
new file mode 100644
index 000000000000..b55d48b7810e
--- /dev/null
+++ b/ext/test_scheduler/tests/034_gc_inside_fiber.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Bug GH-10496 001 (Segfault when garbage collector is invoked inside of fiber) — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+unset($f);
+gc_collect_cycles();
+print "Collected\n";
+
+?>
+--EXPECT--
+Collected
+Cleaned
+Dtor x()
diff --git a/ext/test_scheduler/tests/035_gc_fiber_generator_dtors.phpt b/ext/test_scheduler/tests/035_gc_fiber_generator_dtors.phpt
new file mode 100644
index 000000000000..a65b07bea65d
--- /dev/null
+++ b/ext/test_scheduler/tests/035_gc_fiber_generator_dtors.phpt
@@ -0,0 +1,35 @@
+--TEST--
+GH-19983 (GC Assertion Failure with fibers, generators and destructors) — under test_scheduler
+--SKIPIF--
+
+--INI--
+test_scheduler.enable=1
+memory_limit=16M
+--EXTENSIONS--
+test_scheduler
+--FILE--
+current();
+ });
+ $fiber->start();
+ }
+}
+new a;
+?>
+--EXPECTF--
+Fatal error: Allowed memory size of %d bytes exhausted%s
diff --git a/ext/test_scheduler/tests/036_shutdown_generator_in_fiber.phpt b/ext/test_scheduler/tests/036_shutdown_generator_in_fiber.phpt
new file mode 100644
index 000000000000..bcd2c0e71363
--- /dev/null
+++ b/ext/test_scheduler/tests/036_shutdown_generator_in_fiber.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Bug GH-9916 009 (Entering shutdown sequence with a fiber suspended in a Generator emits an unavoidable fatal error or crashes) — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ new stdClass];
+ print "Not executed\n";
+ }
+})();
+$fiber = new Fiber(function() use ($gen, &$fiber) {
+ $gen->current();
+ print "Not executed\n";
+});
+$fiber->start();
+?>
+==DONE==
+--EXPECT--
+Before suspend
+==DONE==
+Finally
+Not executed
diff --git a/ext/test_scheduler/tests/037_gc_dtor_iterator_loop.phpt b/ext/test_scheduler/tests/037_gc_dtor_iterator_loop.phpt
new file mode 100644
index 000000000000..079439cca1ca
--- /dev/null
+++ b/ext/test_scheduler/tests/037_gc_dtor_iterator_loop.phpt
@@ -0,0 +1,42 @@
+--TEST--
+OSS-Fuzz #471533782: a throwing finally after a suspending destructor — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+ public function __destruct() {
+ try {
+ Fiber::suspend();
+ } finally {
+ throw new Exception();
+ }
+ }
+}
+
+$f = new Fiber(function () {
+ new Cycle();
+ gc_collect_cycles();
+});
+$f->start();
+
+?>
+--EXPECTF--
+Fatal error: Uncaught FiberError: Cannot suspend outside of a fiber in %s:%d
+Stack trace:
+#0 %s(%d): Fiber::suspend()
+#1 [internal function]: Cycle->__destruct()
+#2 {main}
+
+Next Exception in %s:%d
+Stack trace:
+#0 [internal function]: Cycle->__destruct()
+#1 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/038_gc_dtor_iterator_loop_2.phpt b/ext/test_scheduler/tests/038_gc_dtor_iterator_loop_2.phpt
new file mode 100644
index 000000000000..8a88f95418b1
--- /dev/null
+++ b/ext/test_scheduler/tests/038_gc_dtor_iterator_loop_2.phpt
@@ -0,0 +1,43 @@
+--TEST--
+OSS-Fuzz #471533782: a second suspend in finally after a suspending destructor — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+ public function __destruct() {
+ try {
+ Fiber::suspend();
+ } finally {
+ Fiber::suspend();
+ }
+ }
+}
+
+$f = new Fiber(function () {
+ new Cycle();
+ gc_collect_cycles();
+});
+$f->start();
+
+?>
+--EXPECTF--
+Fatal error: Uncaught FiberError: Cannot suspend outside of a fiber in %s:%d
+Stack trace:
+#0 %s(%d): Fiber::suspend()
+#1 [internal function]: Cycle->__destruct()
+#2 {main}
+
+Next FiberError: Cannot suspend outside of a fiber in %s:%d
+Stack trace:
+#0 %s(%d): Fiber::suspend()
+#1 [internal function]: Cycle->__destruct()
+#2 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/039_oom_recursive_fiber.phpt b/ext/test_scheduler/tests/039_oom_recursive_fiber.phpt
new file mode 100644
index 000000000000..82ff15326bcc
--- /dev/null
+++ b/ext/test_scheduler/tests/039_oom_recursive_fiber.phpt
@@ -0,0 +1,29 @@
+--TEST--
+Out of Memory from recursive fiber creation — under test_scheduler
+--INI--
+test_scheduler.enable=1
+memory_limit=2M
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--FILE--
+start();
+ return $fiber;
+}
+
+$fiber = new Fiber('create_fiber');
+$fiber->start();
+
+?>
+--EXPECTF--
+Fatal error: Allowed memory size of %d bytes exhausted%s(tried to allocate %d bytes) in %s on line %d
diff --git a/ext/test_scheduler/tests/040_force_close_catch_dtor_throw.phpt b/ext/test_scheduler/tests/040_force_close_catch_dtor_throw.phpt
new file mode 100644
index 000000000000..eccc4f9e921a
--- /dev/null
+++ b/ext/test_scheduler/tests/040_force_close_catch_dtor_throw.phpt
@@ -0,0 +1,41 @@
+--TEST--
+Suspend in force-closed fiber, catching exception thrown from destructor — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+ })();
+} catch (FiberError $exception) {
+ echo $exception->getMessage(), "\n";
+}
+
+echo "done\n";
+
+?>
+--EXPECTF--
+done
+
+Fatal error: Uncaught FiberError: Cannot suspend in a force-closed fiber in %s:%d
+Stack trace:
+#0 %s(%d): Fiber::suspend()
+#1 [internal function]: {closure:%s:%d}()
+#2 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/041_throw_during_fiber_destruct.phpt b/ext/test_scheduler/tests/041_throw_during_fiber_destruct.phpt
new file mode 100644
index 000000000000..96382048eeb7
--- /dev/null
+++ b/ext/test_scheduler/tests/041_throw_during_fiber_destruct.phpt
@@ -0,0 +1,29 @@
+--TEST--
+Make sure exceptions are rethrown when throwing from fiber destructor — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+unset($fiber);
+throw new Exception("Exception 1");
+?>
+--EXPECTF--
+Fatal error: Uncaught Exception: Exception 1 in %s:%d
+Stack trace:
+#0 {main}
+
+Next Exception: Exception 2 in %s:%d
+Stack trace:
+#0 [internal function]: {closure:%s:%d}()
+#1 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/042_unfinished_fiber_finally.phpt b/ext/test_scheduler/tests/042_unfinished_fiber_finally.phpt
new file mode 100644
index 000000000000..54c56a8e374c
--- /dev/null
+++ b/ext/test_scheduler/tests/042_unfinished_fiber_finally.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Test unfinished fiber with finally block — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+
+unset($fiber); // Destroy fiber object, executing finally block.
+
+echo "done\n";
+
+?>
+--EXPECT--
+fiber
+done
+finally
diff --git a/ext/test_scheduler/tests/043_unfinished_fiber_nested_try.phpt b/ext/test_scheduler/tests/043_unfinished_fiber_nested_try.phpt
new file mode 100644
index 000000000000..894b1996f151
--- /dev/null
+++ b/ext/test_scheduler/tests/043_unfinished_fiber_nested_try.phpt
@@ -0,0 +1,55 @@
+--TEST--
+Test unfinished fiber with nested try/catch blocks — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+
+unset($fiber); // Destroy fiber object, executing finally block.
+
+echo "done\n";
+
+?>
+--EXPECT--
+fiber
+done
+inner finally
+outer finally
diff --git a/ext/test_scheduler/tests/044_unfinished_fiber_suspend_in_finally.phpt b/ext/test_scheduler/tests/044_unfinished_fiber_suspend_in_finally.phpt
new file mode 100644
index 000000000000..8785083af5c2
--- /dev/null
+++ b/ext/test_scheduler/tests/044_unfinished_fiber_suspend_in_finally.phpt
@@ -0,0 +1,44 @@
+--TEST--
+Test unfinished fiber with suspend in finally — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+
+unset($fiber); // Destroy fiber object, executing finally block.
+
+echo "done\n";
+
+?>
+--EXPECT--
+fiber
+inner finally
+done
+outer finally
diff --git a/ext/test_scheduler/tests/045_unfinished_fiber_throw_in_finally.phpt b/ext/test_scheduler/tests/045_unfinished_fiber_throw_in_finally.phpt
new file mode 100644
index 000000000000..eec38512cd60
--- /dev/null
+++ b/ext/test_scheduler/tests/045_unfinished_fiber_throw_in_finally.phpt
@@ -0,0 +1,56 @@
+--TEST--
+Test unfinished fiber with suspend in finally — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage(), "\n";
+ } finally {
+ echo "outer finally\n";
+ }
+
+ try {
+ echo Fiber::suspend();
+ } catch (FiberError $exception) {
+ echo $exception->getMessage(), "\n";
+ }
+});
+
+$fiber->start();
+
+unset($fiber); // Destroy fiber object, executing finally block.
+
+echo "done\n";
+
+?>
+--EXPECT--
+fiber
+done
+inner finally
+finally exception
+outer finally
+Cannot suspend in a force-closed fiber
diff --git a/ext/test_scheduler/tests/046_gc_nested_data_after_dtors.phpt b/ext/test_scheduler/tests/046_gc_nested_data_after_dtors.phpt
new file mode 100644
index 000000000000..519f59ae67b4
--- /dev/null
+++ b/ext/test_scheduler/tests/046_gc_nested_data_after_dtors.phpt
@@ -0,0 +1,44 @@
+--TEST--
+Bug #69446 (GC leak relating to removal of nested data after dtors run) — under test_scheduler
+--INI--
+test_scheduler.enable=1
+zend.enable_gc = 1
+--EXTENSIONS--
+test_scheduler
+--FILE--
+_private[] = 'php';
+ }
+
+ public function __destruct()
+ {
+ global $bar;
+ $bar = $this;
+ }
+}
+
+$foo = new stdclass;
+$foo->foo = $foo;
+$foo->bad = new bad;
+
+unserialize(serialize($foo));
+//unset($foo);
+
+gc_collect_cycles();
+var_dump($bar);
+?>
+--EXPECTF--
+object(bad)#%d (1) {
+ ["_private"]=>
+ array(1) {
+ [0]=>
+ string(3) "php"
+ }
+}
diff --git a/ext/test_scheduler/tests/047_gc_uaf_dtor_order.phpt b/ext/test_scheduler/tests/047_gc_uaf_dtor_order.phpt
new file mode 100644
index 000000000000..70a513199251
--- /dev/null
+++ b/ext/test_scheduler/tests/047_gc_uaf_dtor_order.phpt
@@ -0,0 +1,35 @@
+--TEST--
+Bug #72530: Use After Free in GC with Certain Destructors — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+chtg = $this->ryat;
+ $this->ryat = 1;
+ }
+}
+
+$o = new ryat;
+$o->ryat = $o;
+$x =& $o->chtg;
+
+unset($o);
+gc_collect_cycles();
+var_dump($x);
+
+?>
+--EXPECTF--
+object(ryat)#%d (2) {
+ ["ryat"]=>
+ int(1)
+ ["chtg"]=>
+ *RECURSION*
+}
diff --git a/ext/test_scheduler/tests/048_gc_temp_result_cycle.phpt b/ext/test_scheduler/tests/048_gc_temp_result_cycle.phpt
new file mode 100644
index 000000000000..ce905ec84ecf
--- /dev/null
+++ b/ext/test_scheduler/tests/048_gc_temp_result_cycle.phpt
@@ -0,0 +1,22 @@
+--TEST--
+Bug #78999 (Cycle leak when using function result as temporary) — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+prop = $t;
+ return $t;
+}
+var_dump(get());
+var_dump(gc_collect_cycles());
+?>
+--EXPECTF--
+object(stdClass)#%d (1) {
+ ["prop"]=>
+ *RECURSION*
+}
+int(1)
diff --git a/ext/test_scheduler/tests/049_gc_exceptions_in_dtors.phpt b/ext/test_scheduler/tests/049_gc_exceptions_in_dtors.phpt
new file mode 100644
index 000000000000..dd85a8445c28
--- /dev/null
+++ b/ext/test_scheduler/tests/049_gc_exceptions_in_dtors.phpt
@@ -0,0 +1,37 @@
+--TEST--
+GC 030: GC and exceptions in destructors — under test_scheduler
+--INI--
+test_scheduler.enable=1
+zend.enable_gc=1
+--EXTENSIONS--
+test_scheduler
+--FILE--
+foo = $f2;
+$f2->foo = $f1;
+unset($f1, $f2);
+/* The destructors run in the GC coroutine, so their traces no longer pass
+ * through the gc_collect_cycles() frame of the caller. */
+gc_collect_cycles();
+?>
+--EXPECTF--
+Fatal error: Uncaught Exception: foobar in %s:%d
+Stack trace:
+#0 [internal function]: foo->__destruct()
+#1 {main}
+
+Next Exception: foobar in %s:%d
+Stack trace:
+#0 [internal function]: foo->__destruct()
+#1 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/050_gc_nested_references.phpt b/ext/test_scheduler/tests/050_gc_nested_references.phpt
new file mode 100644
index 000000000000..958d69d77d92
--- /dev/null
+++ b/ext/test_scheduler/tests/050_gc_nested_references.phpt
@@ -0,0 +1,39 @@
+--TEST--
+GC 041: Handling of references in nested data of objects with destructor — under test_scheduler
+--INI--
+test_scheduler.enable=1
+zend.enable_gc = 1
+--EXTENSIONS--
+test_scheduler
+--FILE--
+nested = [];
+$o->nested[] =& $o->nested;
+$o->ryat = $o;
+$x =& $o->chtg;
+unset($o);
+var_dump(gc_collect_cycles());
+var_dump($x);
+?>
+--EXPECTF--
+int(0)
+object(ryat)#%d (3) {
+ ["ryat"]=>
+ *RECURSION*
+ ["chtg"]=>
+ *RECURSION*
+ ["nested"]=>
+ &array(1) {
+ [0]=>
+ *RECURSION*
+ }
+}
diff --git a/ext/test_scheduler/tests/051_gc_props_ht_nested.phpt b/ext/test_scheduler/tests/051_gc_props_ht_nested.phpt
new file mode 100644
index 000000000000..5f7e86f3564d
--- /dev/null
+++ b/ext/test_scheduler/tests/051_gc_props_ht_nested.phpt
@@ -0,0 +1,33 @@
+--TEST--
+Object properties HT may need to be removed from nested data — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+x = new stdClass;
+$t->x->t = $t;
+$a = (array) $t->x;
+unset($t, $a);
+gc_collect_cycles();
+var_dump($x);
+
+?>
+--EXPECTF--
+object(Test)#%d (1) {
+ ["x"]=>
+ object(stdClass)#%d (1) {
+ ["t"]=>
+ *RECURSION*
+ }
+}
diff --git a/ext/test_scheduler/tests/052_gc_buffer_reuse.phpt b/ext/test_scheduler/tests/052_gc_buffer_reuse.phpt
new file mode 100644
index 000000000000..36303400d512
--- /dev/null
+++ b/ext/test_scheduler/tests/052_gc_buffer_reuse.phpt
@@ -0,0 +1,49 @@
+--TEST--
+GC buffer shouldn't get reused when removing nested data — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECTF--
+Deprecated: Creation of dynamic property RegexIterator::$5 is deprecated in %s on line %d
+object(stdClass)#%d (2) {
+ ["5"]=>
+ object(SplStack)#%d (2) {
+ ["flags":"SplDoublyLinkedList":private]=>
+ int(4)
+ ["dllist":"SplDoublyLinkedList":private]=>
+ array(2) {
+ [0]=>
+ *RECURSION*
+ [1]=>
+ object(stdClass)#%d (0) {
+ }
+ }
+ }
+ ["0"]=>
+ object(RegexIterator)#%d (2) {
+ ["replacement"]=>
+ NULL
+ ["5"]=>
+ object(SplStack)#%d (2) {
+ ["flags":"SplDoublyLinkedList":private]=>
+ int(4)
+ ["dllist":"SplDoublyLinkedList":private]=>
+ array(2) {
+ [0]=>
+ *RECURSION*
+ [1]=>
+ object(stdClass)#%d (0) {
+ }
+ }
+ }
+ }
+}
diff --git a/ext/test_scheduler/tests/053_generator_closure_this.phpt b/ext/test_scheduler/tests/053_generator_closure_this.phpt
new file mode 100644
index 000000000000..d09940b88274
--- /dev/null
+++ b/ext/test_scheduler/tests/053_generator_closure_this.phpt
@@ -0,0 +1,24 @@
+--TEST--
+Non-static closures can be generators — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getGenFactory();
+var_dump($genFactory()->current());
+
+?>
+--EXPECTF--
+object(Test)#%d (0) {
+}
diff --git a/ext/test_scheduler/tests/054_generator_nonscalar_keys.phpt b/ext/test_scheduler/tests/054_generator_nonscalar_keys.phpt
new file mode 100644
index 000000000000..acd706b16cdf
--- /dev/null
+++ b/ext/test_scheduler/tests/054_generator_nonscalar_keys.phpt
@@ -0,0 +1,56 @@
+--TEST--
+Generators can return non-scalar keys — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ [4, 5, 6];
+ yield (object) ['a' => 'b'] => (object) ['b' => 'a'];
+ yield 3.14 => 2.73;
+ yield false => true;
+ yield true => false;
+ yield null => null;
+}
+
+foreach (gen() as $k => $v) {
+ var_dump($k, $v);
+}
+
+?>
+--EXPECTF--
+array(3) {
+ [0]=>
+ int(1)
+ [1]=>
+ int(2)
+ [2]=>
+ int(3)
+}
+array(3) {
+ [0]=>
+ int(4)
+ [1]=>
+ int(5)
+ [2]=>
+ int(6)
+}
+object(stdClass)#%d (1) {
+ ["a"]=>
+ string(1) "b"
+}
+object(stdClass)#%d (1) {
+ ["b"]=>
+ string(1) "a"
+}
+float(3.14)
+float(2.73)
+bool(false)
+bool(true)
+bool(true)
+bool(false)
+NULL
+NULL
diff --git a/ext/test_scheduler/tests/055_generator_frames_scan_1.phpt b/ext/test_scheduler/tests/055_generator_frames_scan_1.phpt
new file mode 100644
index 000000000000..7fade94361d9
--- /dev/null
+++ b/ext/test_scheduler/tests/055_generator_frames_scan_1.phpt
@@ -0,0 +1,61 @@
+--TEST--
+GH-15330 003: Do not scan generator frames more than once — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+current());
+ $iterable->next();
+ var_dump("not executed");
+});
+
+$canary->value = $fiber;
+
+$fiber->start();
+
+$iterable->current();
+
+$fiber = $iterable = $canary = null;
+
+gc_collect_cycles();
+
+?>
+==DONE==
+--EXPECTF--
+object(Canary)#%d (1) {
+ ["value"]=>
+ object(Fiber)#%d (0) {
+ }
+}
+string(3) "foo"
+==DONE==
+string(18) "Canary::__destruct"
diff --git a/ext/test_scheduler/tests/056_generator_frames_scan_2.phpt b/ext/test_scheduler/tests/056_generator_frames_scan_2.phpt
new file mode 100644
index 000000000000..137749e119b9
--- /dev/null
+++ b/ext/test_scheduler/tests/056_generator_frames_scan_2.phpt
@@ -0,0 +1,56 @@
+--TEST--
+GH-15330 004: Do not scan generator frames more than once — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+current());
+ $iterable->next();
+ var_dump("not executed");
+});
+
+$canary->value = $fiber;
+
+$fiber->start();
+
+$iterable->current();
+
+$fiber = $iterable = $canary = null;
+
+gc_collect_cycles();
+
+?>
+==DONE==
+--EXPECTF--
+object(Canary)#%d (1) {
+ ["value"]=>
+ object(Fiber)#%d (0) {
+ }
+}
+string(3) "foo"
+==DONE==
+string(18) "Canary::__destruct"
diff --git a/ext/test_scheduler/tests/057_generator_frames_scan_3.phpt b/ext/test_scheduler/tests/057_generator_frames_scan_3.phpt
new file mode 100644
index 000000000000..b4fc611f63dc
--- /dev/null
+++ b/ext/test_scheduler/tests/057_generator_frames_scan_3.phpt
@@ -0,0 +1,57 @@
+--TEST--
+GH-15330 005: Do not scan generator frames more than once — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+current());
+ $f = $iterable->next(...);
+ $f();
+ var_dump("not executed");
+});
+
+$canary->value = $fiber;
+
+$fiber->start();
+
+$iterable->current();
+
+$fiber = $iterable = $canary = null;
+
+gc_collect_cycles();
+
+?>
+==DONE==
+--EXPECTF--
+object(Canary)#%d (1) {
+ ["value"]=>
+ object(Fiber)#%d (0) {
+ }
+}
+string(3) "foo"
+==DONE==
+string(18) "Canary::__destruct"
diff --git a/ext/test_scheduler/tests/058_generator_frames_scan_4.phpt b/ext/test_scheduler/tests/058_generator_frames_scan_4.phpt
new file mode 100644
index 000000000000..5d9912a9c0eb
--- /dev/null
+++ b/ext/test_scheduler/tests/058_generator_frames_scan_4.phpt
@@ -0,0 +1,60 @@
+--TEST--
+GH-15330 006: Do not scan generator frames more than once — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+current());
+ $iterable->next();
+ var_dump("not executed");
+});
+
+$canary->value = $fiber;
+
+$fiber->start();
+
+$iterable->current();
+
+$fiber = $iterable = $canary = null;
+
+gc_collect_cycles();
+
+?>
+==DONE==
+--EXPECTF--
+object(Canary)#%d (1) {
+ ["value"]=>
+ object(Fiber)#%d (0) {
+ }
+}
+string(3) "foo"
+==DONE==
+string(18) "Canary::__destruct"
diff --git a/ext/test_scheduler/tests/059_generator_shutdown_dtor_order.phpt b/ext/test_scheduler/tests/059_generator_shutdown_dtor_order.phpt
new file mode 100644
index 000000000000..919bb6999bf9
--- /dev/null
+++ b/ext/test_scheduler/tests/059_generator_shutdown_dtor_order.phpt
@@ -0,0 +1,57 @@
+--TEST--
+GH-15866: Core dumped in Zend/zend_generators.c — under test_scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+next();
+ } finally {
+ print "Fiber finally\n";
+ }
+});
+$canary->value = $fiber;
+$fiber->start();
+
+// Reset roots
+gc_collect_cycles();
+
+// Add to roots, create garbage cycles
+$fiber = $iterable = $canary = null;
+
+print "Collect cycles\n";
+gc_collect_cycles();
+
+?>
+==DONE==
+--EXPECT--
+Collect cycles
+==DONE==
+Generator finally
+Fiber finally
+Canary::__destruct
diff --git a/ext/test_scheduler/tests/060_shutdown_dtor_suspend_object_pass.phpt b/ext/test_scheduler/tests/060_shutdown_dtor_suspend_object_pass.phpt
new file mode 100644
index 000000000000..ec5868e02336
--- /dev/null
+++ b/ext/test_scheduler/tests/060_shutdown_dtor_suspend_object_pass.phpt
@@ -0,0 +1,44 @@
+--TEST--
+Object destructor suspends during the object-store shutdown pass: the iterator carries the pass on
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+name}\n";
+ }
+}
+
+Holder::$keep[] = new Suspends();
+Holder::$keep[] = new Plain("b");
+Holder::$keep[] = new Plain("c");
+
+echo "==DONE==\n";
+?>
+--EXPECT--
+==DONE==
+Suspends::dtor enter
+spawned body
+Plain::dtor b
+Plain::dtor c
+Suspends::dtor leave
diff --git a/ext/test_scheduler/tests/061_shutdown_dtor_pass_owner_killed.phpt b/ext/test_scheduler/tests/061_shutdown_dtor_pass_owner_killed.phpt
new file mode 100644
index 000000000000..162d56e2128c
--- /dev/null
+++ b/ext/test_scheduler/tests/061_shutdown_dtor_pass_owner_killed.phpt
@@ -0,0 +1,62 @@
+--TEST--
+Pass-owning shutdown iterator dies mid-pass: warn and give up on the remaining destructors
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECTF--
+==DONE==
+ParksForever::dtor enter
+Fatal::dtor enter
+
+Deprecated: Passing E_USER_ERROR to trigger_error() is deprecated since 8.4, throw an exception or call exit with a string message instead in %s on line %d
+
+Fatal error: boom in %s on line %d
+
+Warning: Shutdown destructors coroutine was not finished properly in Unknown on line %d
+
+Fatal error: Uncaught TestScheduler\CancellationError: Deadlock detected in [no active file]:%d
+Stack trace:
+#0 {main}
+ thrown in [no active file] on line %d
+
+Fatal error: Uncaught TestScheduler\DeadlockError: Deadlock detected: no active coroutines, 2 coroutines in waiting in [no active file]:%d
+Stack trace:
+#0 {main}
+ thrown in [no active file] on line %d
diff --git a/ext/test_scheduler/tests/062_gc_spawn_named_arg_cycle.phpt b/ext/test_scheduler/tests/062_gc_spawn_named_arg_cycle.phpt
new file mode 100644
index 000000000000..25c0cce3879b
--- /dev/null
+++ b/ext/test_scheduler/tests/062_gc_spawn_named_arg_cycle.phpt
@@ -0,0 +1,28 @@
+--TEST--
+GC collects a cycle running through spawn()'s named arguments
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $co;
+
+TestScheduler\cancel($co);
+TestScheduler\await(TestScheduler\spawn(fn () => 1)); // let the loop tick
+
+$weak = WeakReference::create($co);
+unset($co, $obj);
+
+var_dump(gc_collect_cycles() > 0);
+var_dump($weak->get() === null);
+
+echo "==DONE==\n";
+?>
+--EXPECT--
+bool(true)
+bool(true)
+==DONE==
diff --git a/ext/test_scheduler/tests/063_spawn_context_failure.phpt b/ext/test_scheduler/tests/063_spawn_context_failure.phpt
new file mode 100644
index 000000000000..3e1660eac1f4
--- /dev/null
+++ b/ext/test_scheduler/tests/063_spawn_context_failure.phpt
@@ -0,0 +1,28 @@
+--TEST--
+A spawn() failing to create its fiber context leaves no phantom coroutine behind
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ 1);
+ echo "spawn unexpectedly succeeded\n";
+} catch (Throwable $e) {
+ echo get_class($e), ": ", $e->getMessage(), "\n";
+}
+
+ini_set('fiber.stack_size', '1048576');
+
+var_dump(TestScheduler\await(TestScheduler\spawn(fn () => "ok")));
+
+echo "==DONE==\n";
+?>
+--EXPECTF--
+Exception: Fiber stack size is too small, it needs to be at least %d bytes
+string(2) "ok"
+==DONE==
diff --git a/ext/test_scheduler/tests/064_exit_in_fiber_shutdown.phpt b/ext/test_scheduler/tests/064_exit_in_fiber_shutdown.phpt
new file mode 100644
index 000000000000..f044712fc7d0
--- /dev/null
+++ b/ext/test_scheduler/tests/064_exit_in_fiber_shutdown.phpt
@@ -0,0 +1,21 @@
+--TEST--
+test_scheduler: exit() inside a fiber raises the shutdown notification
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+
+echo "not reached\n";
+?>
+--EXPECT--
+fiber: running
+[scheduler] shutdown requested
diff --git a/ext/test_scheduler/tests/065_fiber_disposal_no_shutdown.phpt b/ext/test_scheduler/tests/065_fiber_disposal_no_shutdown.phpt
new file mode 100644
index 000000000000..a7518fba1e58
--- /dev/null
+++ b/ext/test_scheduler/tests/065_fiber_disposal_no_shutdown.phpt
@@ -0,0 +1,27 @@
+--TEST--
+test_scheduler: disposing a suspended fiber does not raise shutdown
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+
+/* The engine sends a graceful exit into the parked stack to unwind it. That
+ * is a disposal, not an exit(), and must not shut the scheduler down. */
+$fiber = null;
+gc_collect_cycles();
+
+echo "disposed\n";
+?>
+--EXPECT--
+fiber: parking
+disposed
diff --git a/ext/test_scheduler/tests/066_exit_in_coroutine_scheduler_owned.phpt b/ext/test_scheduler/tests/066_exit_in_coroutine_scheduler_owned.phpt
new file mode 100644
index 000000000000..cd3874b51e82
--- /dev/null
+++ b/ext/test_scheduler/tests/066_exit_in_coroutine_scheduler_owned.phpt
@@ -0,0 +1,23 @@
+--TEST--
+test_scheduler: exit() inside a scheduler coroutine ends the request, scheduler handles it itself
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main: end
+coroutine: running
diff --git a/ext/test_scheduler/tests/067_exit_in_main_no_shutdown.phpt b/ext/test_scheduler/tests/067_exit_in_main_no_shutdown.phpt
new file mode 100644
index 000000000000..b6efdcf19df2
--- /dev/null
+++ b/ext/test_scheduler/tests/067_exit_in_main_no_shutdown.phpt
@@ -0,0 +1,16 @@
+--TEST--
+test_scheduler: exit() in the main flow does not raise the shutdown notification
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main: exiting
diff --git a/main/main.c b/main/main.c
index 22304cad1b48..1d9de055f15f 100644
--- a/main/main.c
+++ b/main/main.c
@@ -87,6 +87,8 @@
#include "SAPI.h"
#include "rfc1867.h"
+#include "zend_async_API.h"
+
#include "main_arginfo.h"
/* }}} */
@@ -1942,6 +1944,21 @@ void php_request_shutdown(void *dummy)
zend_call_destructors();
} zend_end_try();
+ /* Before PHP shuts down completely, control goes to the coroutines one
+ * last time: the destructors above may have spawned or resumed some. */
+ zend_try {
+ ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false);
+
+ /* The final pass may leave an exception in EG with no caller left to
+ * handle it; report it as php_execute_script() does. */
+ if (UNEXPECTED(EG(exception))) {
+ zend_exception_error(EG(exception), E_ERROR);
+ }
+ } zend_end_try();
+
+ /* From here on everything runs synchronously. */
+ ZEND_ASYNC_DEACTIVATE;
+
/* 3. Flush all output buffers */
zend_try {
php_output_end_all();
@@ -2189,6 +2206,7 @@ zend_result php_module_startup(sapi_module_struct *sf, zend_module_entry *additi
php_startup_ticks();
#endif
gc_globals_ctor();
+ zend_async_globals_ctor();
zend_observer_startup();
#if ZEND_DEBUG
@@ -2509,6 +2527,8 @@ void php_module_shutdown(void)
module_initialized = false;
+ zend_async_api_shutdown();
+
#ifndef ZTS
core_globals_dtor(&core_globals);
gc_globals_dtor();
@@ -2595,6 +2615,8 @@ PHPAPI bool php_execute_script_ex(zend_file_handle *primary_file, zval *retval)
zend_set_timeout(zend_ini_long_literal("max_execution_time"), false);
}
+ result = ZEND_ASYNC_SCHEDULER_LAUNCH();
+
if (prepend_file_p && result) {
result = zend_execute_script(ZEND_REQUIRE, NULL, prepend_file_p) == SUCCESS;
}
@@ -2604,7 +2626,10 @@ PHPAPI bool php_execute_script_ex(zend_file_handle *primary_file, zval *retval)
if (append_file_p && result) {
result = zend_execute_script(ZEND_REQUIRE, NULL, append_file_p) == SUCCESS;
}
+
+ ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false);
} zend_catch {
+ ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(true);
result = false;
} zend_end_try();
@@ -2778,7 +2803,7 @@ PHPAPI void php_reserve_tsrm_memory(void)
#ifdef HAVE_JIT
TSRM_ALIGNED_SIZE(sizeof(zend_jit_globals)) +
#endif
- 0
+ TSRM_ALIGNED_SIZE(sizeof(zend_async_globals_t))
);
}
/* }}} */
diff --git a/win32/build/config.w32 b/win32/build/config.w32
index 012c52499b64..bc0525bf357d 100644
--- a/win32/build/config.w32
+++ b/win32/build/config.w32
@@ -241,7 +241,7 @@ ADD_SOURCES("Zend", "zend_language_parser.c zend_language_scanner.c \
zend_float.c zend_string.c zend_generators.c zend_virtual_cwd.c zend_ast.c \
zend_inheritance.c zend_smart_str.c zend_cpuinfo.c zend_observer.c zend_system_id.c \
zend_enum.c zend_fibers.c zend_atomic.c zend_hrtime.c zend_frameless_function.c zend_property_hooks.c \
- zend_lazy_objects.c zend_autoload.c zend_partial.c");
+ zend_lazy_objects.c zend_autoload.c zend_partial.c zend_async_API.c");
ADD_SOURCES("Zend\\Optimizer", "zend_optimizer.c pass1.c pass3.c optimize_func_calls.c block_pass.c optimize_temp_vars_5.c nop_removal.c compact_literals.c zend_cfg.c zend_dfg.c dfa_pass.c zend_ssa.c zend_inference.c zend_func_info.c zend_call_graph.c zend_dump.c escape_analysis.c compact_vars.c dce.c sccp.c scdf.c");
var PHP_ASSEMBLER = PATH_PROG({
From bd18e378c8a5d73e3f1c35bbb48abf836d3e59fd Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Thu, 1 Oct 2026 12:42:24 +0300
Subject: [PATCH 02/33] async: keep the main coroutine alive until the loop is
done with it
ts_main_suspend() dropped the last reference to the main coroutine before
switching to the loop. When main had yielded before, the loop is parked in
ts_switch_into(main) and reads the freed object when the switch lands there
(heap-use-after-free under ASAN in 40 of the 61 test_scheduler tests).
---
ext/test_scheduler/test_scheduler.c | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index 1147ed600c44..41d90bb5e9bd 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -1492,6 +1492,12 @@ static bool ts_main_suspend(bool is_bailout)
}
ZEND_ASYNC_MAIN_COROUTINE = NULL;
+
+ /* If main ever yielded, the loop is parked inside ts_switch_into(main) and
+ * reads the object when this switch lands there: keep it alive until the
+ * loop is done. Its handle stays taken, so the loop's own retire of it
+ * cannot hit another coroutine. */
+ GC_ADDREF(&main_coro->std);
ts_coroutine_retire(main_coro);
/* Back on the context the engine owns: the copy the main coroutine ran
@@ -1505,6 +1511,8 @@ static bool ts_main_suspend(bool is_bailout)
zend_fiber_switch_context(&transfer);
+ OBJ_RELEASE(&main_coro->std);
+
/* The loop is done and has handed control back to EG(main_fiber_context)
* — this stack, right here. Its own coroutine is spent either way. */
TSG(scheduler) = NULL;
From 45c834ad383a71b496c7a7377c4347424c3d47d5 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Thu, 1 Oct 2026 12:42:24 +0300
Subject: [PATCH 03/33] async: return a fiber body's value into its coroutine,
not the Fiber object
The body's frame returned into &fiber->result. A Fiber object dropped while
its body is parked is freed at once, and the body still writes the return
slot while it unwinds (heap-use-after-free under ASAN in 7 test_scheduler
tests, 6 of them hidden behind the main coroutine one). The value now goes
to the coroutine's result and moves to the fiber if the fiber is still alive.
---
Zend/zend_fibers.c | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index 4d93457aff04..e84c4c5ba04a 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -803,7 +803,11 @@ static void zend_fiber_coroutine_entry(void)
bool is_bailout = false;
zend_try {
- coroutine->fcall->fci.retval = &fiber->result;
+ /* Not &fiber->result: the Fiber object may be dropped while the body
+ * is parked, and the body still writes its return value on the way
+ * out. The coroutine outlives the body; the value moves to the fiber
+ * below if the fiber is still there. */
+ coroutine->fcall->fci.retval = &coroutine->result;
zend_call_function(&coroutine->fcall->fci, &coroutine->fcall->fci_cache);
} zend_catch {
is_bailout = true;
@@ -833,6 +837,9 @@ static void zend_fiber_coroutine_entry(void)
zend_coroutine_t *caller = NULL;
if (fiber != NULL) {
+ ZVAL_COPY_VALUE(&fiber->result, &coroutine->result);
+ ZVAL_UNDEF(&coroutine->result);
+
/* The caller's frame may be gone by the time anything looks at this
* stack again. */
if (fiber->stack_bottom != NULL) {
From 6111c75b90c726bd4bc96bf88e85ea9b33d0b233 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Thu, 1 Oct 2026 12:45:04 +0300
Subject: [PATCH 04/33] async: shorten two comments
---
Zend/zend_fibers.c | 6 ++----
ext/test_scheduler/test_scheduler.c | 6 ++----
2 files changed, 4 insertions(+), 8 deletions(-)
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index e84c4c5ba04a..a24225291d02 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -803,10 +803,8 @@ static void zend_fiber_coroutine_entry(void)
bool is_bailout = false;
zend_try {
- /* Not &fiber->result: the Fiber object may be dropped while the body
- * is parked, and the body still writes its return value on the way
- * out. The coroutine outlives the body; the value moves to the fiber
- * below if the fiber is still there. */
+ /* The body writes this slot while unwinding, possibly after the
+ * Fiber object is freed; the coroutine outlives the body. */
coroutine->fcall->fci.retval = &coroutine->result;
zend_call_function(&coroutine->fcall->fci, &coroutine->fcall->fci_cache);
} zend_catch {
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index 41d90bb5e9bd..a18276402d5a 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -1493,10 +1493,8 @@ static bool ts_main_suspend(bool is_bailout)
ZEND_ASYNC_MAIN_COROUTINE = NULL;
- /* If main ever yielded, the loop is parked inside ts_switch_into(main) and
- * reads the object when this switch lands there: keep it alive until the
- * loop is done. Its handle stays taken, so the loop's own retire of it
- * cannot hit another coroutine. */
+ /* The loop may be parked in ts_switch_into(main), which reads the object
+ * when this switch lands there: hold it until the loop is done. */
GC_ADDREF(&main_coro->std);
ts_coroutine_retire(main_coro);
From 5d5fe3520bb332f17e8a3c20d5ef0393f4b87b6a Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Thu, 1 Oct 2026 13:10:50 +0300
Subject: [PATCH 05/33] async: unwind a bailed-out request without allocating
the loop's VM stack
The loop allocated its VM stack before checking the bailout flag. After an
out-of-memory bailout that allocation failed again and raised a second
fatal error in Unknown on line 0 (Zend/tests/fibers/gh19983.phpt under the
scheduler, ASAN, once merged with master). The bailout branch only switches
between other coroutines' contexts and needs no stack of its own.
---
ext/test_scheduler/test_scheduler.c | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index a18276402d5a..35b24c41de99 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -1124,17 +1124,18 @@ static ZEND_STACK_ALIGNED void ts_scheduler_entry(zend_fiber_transfer *transfer)
EG(vm_stack) = NULL;
zend_first_try {
- ts_vm_stack_start(self);
-
ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true;
/* The main flow bailed out: there is nothing left to schedule, only
- * stacks to unwind. */
+ * stacks to unwind. No VM stack: after an out-of-memory bailout there
+ * may be no memory left for one. */
if (UNEXPECTED(bailout)) {
ts_bailout_all();
goto done;
}
+ ts_vm_stack_start(self);
+
for (;;) {
ts_run_microtasks();
From 9944e2d7b95b7f737d444a6d117159dbbdf4b16c Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Thu, 1 Oct 2026 17:15:19 +0300
Subject: [PATCH 06/33] async: version the scheduler API and refuse a provider
built for another
ZEND_MODULE_API_NO changes once per PHP version and does not tell two
revisions of this API apart; `size` only covers slots appended at the end.
ZEND_ASYNC_API_VERSION is the date of the last incompatible change, as
PDO_DRIVER_API is for PDO drivers. test_scheduler stays loaded but inert
when refused instead of failing startup without a message.
---
Zend/zend_async_API.c | 10 +++++++++
Zend/zend_async_API.h | 13 +++++++-----
ext/test_scheduler/test_scheduler.c | 14 +++++++++++--
.../tests/068_api_version_mismatch.phpt | 21 +++++++++++++++++++
4 files changed, 51 insertions(+), 7 deletions(-)
create mode 100644 ext/test_scheduler/tests/068_api_version_mismatch.phpt
diff --git a/Zend/zend_async_API.c b/Zend/zend_async_API.c
index 02cdcb604bb6..0a4815440ea2 100644
--- a/Zend/zend_async_API.c
+++ b/Zend/zend_async_API.c
@@ -525,6 +525,16 @@ ZEND_API bool zend_async_scheduler_register(
return false;
}
+ if (api->version != ZEND_ASYNC_API_VERSION) {
+ zend_error(E_CORE_WARNING,
+ "The module %s cannot register an Async scheduler: it was built for Async API"
+ " version %" PRIu32 ", the core provides %d",
+ module,
+ api->version,
+ ZEND_ASYNC_API_VERSION);
+ return false;
+ }
+
#ifdef ZTS
tsrm_mutex_lock(scheduler_mutex);
#endif
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 469ddf1f7fdd..76b072fc5e1b 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -393,16 +393,19 @@ typedef bool (*zend_async_coroutine_remove_awaiting_info_t)(
* anything it can name. The caller owns the array. */
typedef zend_array *(*zend_async_coroutine_get_awaiting_info_t)(zend_coroutine_t *coroutine);
+/* Date of the last incompatible change to this API: a changed slot signature
+ * or meaning, a reordered field. Appending a slot does not change it. */
+#define ZEND_ASYNC_API_VERSION 20261001
+
/**
* Scheduler API bundle. A provider fills the struct and calls
- * zend_async_scheduler_register(). New slots are appended at the end only;
- * `size` lets the core detect how much of the struct the provider knows.
- * ABI compatibility rides on the standard PHP module API (ZEND_MODULE_API_NO),
- * enforced when the provider extension is loaded — there is no separate
- * Async API version.
+ * zend_async_scheduler_register(), which refuses a `version` other than
+ * ZEND_ASYNC_API_VERSION. New slots are appended at the end only; `size`
+ * lets the core detect how much of the struct the provider knows.
*/
typedef struct _zend_async_scheduler_api_s {
size_t size; /* sizeof(zend_async_scheduler_api_t) at provider build time */
+ uint32_t version; /* ZEND_ASYNC_API_VERSION at provider build time */
zend_async_new_coroutine_t new_coroutine;
zend_async_gc_new_coroutine_t gc_new_coroutine;
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index 35b24c41de99..ba986a6c5e9a 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -163,6 +163,8 @@ ZEND_DECLARE_MODULE_GLOBALS(test_scheduler)
* in with test_scheduler.enable=1. */
PHP_INI_BEGIN()
PHP_INI_ENTRY("test_scheduler.enable", "0", PHP_INI_SYSTEM, NULL)
+ /* Tests only: register as if built for this Async API version; 0 is the real one. */
+ PHP_INI_ENTRY("test_scheduler.api_version", "0", PHP_INI_SYSTEM, NULL)
PHP_INI_END()
/* False when disabled: MINIT registered nothing. */
@@ -1819,8 +1821,9 @@ PHP_METHOD(TestScheduler_Coroutine, getAwaitingInfo)
/// Module
///////////////////////////////////////////////////////////////////
-static const zend_async_scheduler_api_t ts_scheduler_api = {
+static zend_async_scheduler_api_t ts_scheduler_api = {
.size = sizeof(zend_async_scheduler_api_t),
+ .version = ZEND_ASYNC_API_VERSION,
.new_coroutine = ts_new_coroutine,
.gc_new_coroutine = ts_gc_new_coroutine,
.enqueue_coroutine = ts_enqueue,
@@ -1878,8 +1881,15 @@ PHP_MINIT_FUNCTION(test_scheduler)
ts_coroutine_handlers.get_gc = ts_coroutine_object_gc;
ts_coroutine_handlers.clone_obj = NULL;
+ const zend_long api_version = zend_ini_long(ZEND_STRL("test_scheduler.api_version"), 0);
+
+ if (api_version != 0) {
+ ts_scheduler_api.version = (uint32_t) api_version;
+ }
+
+ /* Refused (the core warned why): stay loaded but inert rather than abort startup. */
if (!zend_async_scheduler_register("test_scheduler", &ts_scheduler_api)) {
- return FAILURE;
+ return SUCCESS;
}
ts_registered = true;
diff --git a/ext/test_scheduler/tests/068_api_version_mismatch.phpt b/ext/test_scheduler/tests/068_api_version_mismatch.phpt
new file mode 100644
index 000000000000..25fcd9d48dcd
--- /dev/null
+++ b/ext/test_scheduler/tests/068_api_version_mismatch.phpt
@@ -0,0 +1,21 @@
+--TEST--
+test_scheduler: a provider built for another Async API version is refused
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+test_scheduler.api_version=1
+--FILE--
+ 1);
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+?>
+--EXPECTF--
+Warning: The module test_scheduler cannot register an Async scheduler: it was built for Async API version 1, the core provides %d in Unknown on line 0
+started
+The scheduler is not running
From 996a9bd70470232c735371993343a4cac21ef9a8 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:16:07 +0000
Subject: [PATCH 07/33] async: mark a coroutine started when its body begins
ZEND_COROUTINE_IS_STARTED read the status, which cannot tell a coroutine
waiting for its first run from one that yielded: both are QUEUED. The new
F_STARTED bit is set by the scheduler just before the body's first
instruction and never for a coroutine cancelled before it ran. A fiber
dropped while its coroutine is still queued now cancels it as well; one
whose enqueue failed is only released.
---
Zend/zend_async_API.h | 12 ++++-
Zend/zend_fibers.c | 15 +++---
ext/test_scheduler/test_scheduler.c | 23 ++++----
ext/test_scheduler/tests/069_is_started.phpt | 55 ++++++++++++++++++++
4 files changed, 87 insertions(+), 18 deletions(-)
create mode 100644 ext/test_scheduler/tests/069_is_started.phpt
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 76b072fc5e1b..2599ebe0a97b 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -148,6 +148,12 @@ struct _zend_coroutine_s {
* object, for a provider whose coroutine and object live in separate
* allocations. */
#define ZEND_COROUTINE_F_OBJ_REF (1u << 7)
+/* The body began executing. The status cannot say it: a coroutine waiting for
+ * its first run and one that yielded are both QUEUED. The scheduler sets it immediately before the body's
+ * first instruction, the main coroutine's included, and never for a coroutine
+ * whose first entry carries an error: one cancelled before it ran never
+ * started. */
+#define ZEND_COROUTINE_F_STARTED (1u << 8)
#define ZEND_COROUTINE_IS_CANCELLED(coroutine) \
(((coroutine)->flags & ZEND_COROUTINE_F_CANCELLED) != 0)
@@ -160,6 +166,10 @@ struct _zend_coroutine_s {
#define ZEND_COROUTINE_IS_FIBER(coroutine) (((coroutine)->flags & ZEND_COROUTINE_F_FIBER) != 0)
#define ZEND_COROUTINE_SET_FIBER(coroutine) ((coroutine)->flags |= ZEND_COROUTINE_F_FIBER)
+#define ZEND_COROUTINE_IS_STARTED(coroutine) \
+ (((coroutine)->flags & ZEND_COROUTINE_F_STARTED) != 0)
+#define ZEND_COROUTINE_SET_STARTED(coroutine) ((coroutine)->flags |= ZEND_COROUTINE_F_STARTED)
+
/* The zend_object of a coroutine, or NULL for a plain C coroutine.
* Embedded model: the object lives at object_offset within the same
* allocation. OBJ_REF model: a zend_object* is stored at object_offset. */
@@ -186,8 +196,6 @@ struct _zend_coroutine_s {
} while (0)
/* Lifecycle predicates over the packed status. */
-#define ZEND_COROUTINE_IS_STARTED(coroutine) \
- (ZEND_COROUTINE_STATUS(coroutine) != ZEND_COROUTINE_STATUS_CREATED)
#define ZEND_COROUTINE_IS_QUEUED(coroutine) \
(ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_QUEUED)
#define ZEND_COROUTINE_IS_RUNNING(coroutine) \
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index 1fb0cd3be79d..d1fc9df1cce3 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -753,8 +753,10 @@ static void zend_fiber_coroutine_dispose(zend_coroutine_t *coroutine)
/* The fiber lets go of its coroutine. Ownership is one-way (fiber → coroutine,
* never back), which keeps the fiber collectable. A fiber that dies while its
* body is still parked cancels it — the body unwinds through its finally
- * blocks, like a force-closed legacy fiber. */
-static void zend_fiber_release_coroutine(zend_fiber *fiber)
+ * blocks, like a force-closed legacy fiber. A body queued but not yet run is
+ * cancelled too, so it never starts; without cancel_unfinished the coroutine
+ * is only released, for one that was never queued. */
+static void zend_fiber_release_coroutine(zend_fiber *fiber, bool cancel_unfinished)
{
zend_coroutine_t *coroutine = fiber->coroutine;
@@ -767,8 +769,7 @@ static void zend_fiber_release_coroutine(zend_fiber *fiber)
coroutine->extended_data = NULL;
fiber->coroutine = NULL;
- if (ZEND_ASYNC_ON && ZEND_COROUTINE_IS_STARTED(coroutine)
- && !ZEND_COROUTINE_IS_FINISHED(coroutine)) {
+ if (cancel_unfinished && ZEND_ASYNC_ON && !ZEND_COROUTINE_IS_FINISHED(coroutine)) {
ZEND_ASYNC_CANCEL(coroutine, zend_create_graceful_exit(), true);
}
@@ -1036,7 +1037,7 @@ static void zend_fiber_coroutine_start(zend_fiber *fiber, zval *return_value)
fiber->coroutine->fcall = fcall;
if (UNEXPECTED(!ZEND_ASYNC_ENQUEUE_COROUTINE(fiber->coroutine))) {
- zend_fiber_release_coroutine(fiber);
+ zend_fiber_release_coroutine(fiber, false);
return;
}
@@ -1109,7 +1110,7 @@ static void zend_fiber_object_destroy(zend_object *object)
* it. The legacy path below never applies (fiber->context uninitialized). */
if (fiber->coroutine != NULL) {
fiber->flags |= ZEND_FIBER_FLAG_DESTROYED;
- zend_fiber_release_coroutine(fiber);
+ zend_fiber_release_coroutine(fiber, true);
return;
}
@@ -1161,7 +1162,7 @@ static void zend_fiber_object_free(zend_object *object)
/* A fiber that was never suspended (so dtor_obj had nothing to close) can
* still own a coroutine: one that never started, or one that already
* finished. */
- zend_fiber_release_coroutine(fiber);
+ zend_fiber_release_coroutine(fiber, true);
zval_ptr_dtor(&fiber->fci.function_name);
zval_ptr_dtor(&fiber->result);
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index ba986a6c5e9a..55e0a8148f14 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -986,12 +986,10 @@ static ZEND_STACK_ALIGNED void ts_coroutine_entry(zend_fiber_transfer *transfer)
ZEND_ASSERT(ts != NULL && "A coroutine must be current when its context starts");
- if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT)) {
- /* Unwound by ts_bailout_all() before the body ever ran: skip it. */
- bailout = true;
- zval_ptr_dtor(&transfer->value);
- ZVAL_UNDEF(&transfer->value);
- } else if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) {
+ /* ts_bailout_all() unwinds only coroutines whose body started. */
+ ZEND_ASSERT(!(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT));
+
+ if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) {
/* Cancelled before the body ever ran. */
ts->coro.exception = Z_OBJ(transfer->value);
ZVAL_UNDEF(&transfer->value);
@@ -1003,6 +1001,7 @@ static ZEND_STACK_ALIGNED void ts_coroutine_entry(zend_fiber_transfer *transfer)
zend_first_try {
ts_vm_stack_start(ts);
+ ZEND_COROUTINE_SET_STARTED(&ts->coro);
if (ts->coro.internal_entry != NULL) {
ts->coro.internal_entry();
@@ -1090,6 +1089,11 @@ static void ts_bailout_all(void)
}
ZEND_HASH_FOREACH_END();
+ /* Every coroutine is finished: a queue entry left behind would
+ * send a loop rebuilt later into a context that never ran. */
+ TSG(queue).head = 0;
+ TSG(queue).count = 0;
+
return;
}
@@ -1328,6 +1332,7 @@ static ts_coroutine_t *ts_adopt_main_context(void)
main_coro->context = *zero_context;
main_coro->context_is_main = true;
main_coro->context_created = true;
+ ZEND_COROUTINE_SET_STARTED(&main_coro->coro);
EG(current_fiber_context) = &main_coro->context;
@@ -1439,7 +1444,8 @@ static zend_execute_data *ts_coroutine_execute_data(zend_coroutine_t *coroutine)
/* Cancellation is a resume with an error: the coroutine wakes inside the
* suspend it is parked in, the error is thrown there, and the body unwinds
- * through its own finally blocks. */
+ * through its own finally blocks. A coroutine that has not run yet receives
+ * the error at its first entry and never starts. */
static bool ts_cancel(
zend_coroutine_t *coroutine, zend_object *error, bool transfer_error, const bool is_safely)
{
@@ -1450,8 +1456,7 @@ static bool ts_cancel(
* here for the coroutine it is already unwinding — re-enqueuing it leaves a
* stale entry that the loop later switches into after the context is gone.
* Cancellation is idempotent: the first graceful exit wins. */
- if (ZEND_COROUTINE_IS_FINISHED(coroutine) || !ZEND_COROUTINE_IS_STARTED(coroutine)
- || ZEND_COROUTINE_IS_CANCELLED(coroutine)) {
+ if (ZEND_COROUTINE_IS_FINISHED(coroutine) || ZEND_COROUTINE_IS_CANCELLED(coroutine)) {
if (error != NULL && transfer_error) {
OBJ_RELEASE(error);
}
diff --git a/ext/test_scheduler/tests/069_is_started.phpt b/ext/test_scheduler/tests/069_is_started.phpt
new file mode 100644
index 000000000000..8c0403677d42
--- /dev/null
+++ b/ext/test_scheduler/tests/069_is_started.phpt
@@ -0,0 +1,55 @@
+--TEST--
+test_scheduler: isStarted() is true once the body began executing, never for a coroutine cancelled before it ran
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isStarted());
+
+$worker = spawn(function () {
+ var_dump(current()->isStarted());
+ suspend();
+ var_dump(current()->isStarted());
+});
+
+// Queued for its first run: QUEUED, but the body has not begun.
+var_dump($worker->isStarted());
+
+spawn(function () use ($worker) {
+ // Parked, then queued again by resume(): started both times.
+ var_dump($worker->isStarted());
+ resume($worker);
+ var_dump($worker->isStarted());
+});
+
+await($worker);
+var_dump($worker->isStarted());
+
+$unstarted = spawn(function () {
+ echo "unreachable body\n";
+});
+cancel($unstarted);
+
+try {
+ await($unstarted);
+} catch (TestScheduler\CancellationError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+var_dump($unstarted->isFinished(), $unstarted->isStarted());
+?>
+--EXPECT--
+bool(true)
+bool(false)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+The coroutine has been cancelled
+bool(true)
+bool(false)
From 5add2bcac221a5595a8d43187597bc651923239c Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:18:20 +0000
Subject: [PATCH 08/33] async: read F_CANCELLED as a request, not as a final
state
A coroutine may catch its cancellation and go on, so a cancelled fiber
may suspend again in finally, and test_scheduler lets a cancelled
coroutine park and delivers a later cancel once the earlier one was
thrown. Only a running coroutine or one with an error still pending
ignores a cancel, which keeps a fiber destroyed inside its own
force-close from queueing itself twice.
---
Zend/zend_fibers.c | 2 +-
ext/test_scheduler/test_scheduler.c | 26 ++++++++-------
ext/test_scheduler/tests/018_cancel.phpt | 18 +++++++---
...0_cancelled_fiber_suspends_in_finally.phpt | 33 +++++++++++++++++++
4 files changed, 61 insertions(+), 18 deletions(-)
create mode 100644 ext/test_scheduler/tests/070_cancelled_fiber_suspends_in_finally.phpt
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index d1fc9df1cce3..f98815b7979a 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -1373,7 +1373,7 @@ ZEND_METHOD(Fiber, suspend)
RETURN_THROWS();
}
- if (UNEXPECTED(current->extended_data == NULL || ZEND_COROUTINE_IS_CANCELLED(current))) {
+ if (UNEXPECTED(current->extended_data == NULL)) {
zend_throw_error(zend_ce_fiber_error, "Cannot suspend in a force-closed fiber");
RETURN_THROWS();
}
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index 55e0a8148f14..823c17abff96 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -1451,12 +1451,7 @@ static bool ts_cancel(
{
(void) is_safely;
- /* Nothing to unwind, or a cancellation is already in flight. The last case
- * matters most: a fiber destroyed from inside its own force-close re-enters
- * here for the coroutine it is already unwinding — re-enqueuing it leaves a
- * stale entry that the loop later switches into after the context is gone.
- * Cancellation is idempotent: the first graceful exit wins. */
- if (ZEND_COROUTINE_IS_FINISHED(coroutine) || ZEND_COROUTINE_IS_CANCELLED(coroutine)) {
+ if (ZEND_COROUTINE_IS_FINISHED(coroutine)) {
if (error != NULL && transfer_error) {
OBJ_RELEASE(error);
}
@@ -1466,6 +1461,19 @@ static bool ts_cancel(
ZEND_COROUTINE_SET_CANCELLED(coroutine);
+ /* The coroutine is running, or a cancellation is already in flight. A running one matters most: a fiber destroyed from inside its
+ * own force-close re-enters here for the coroutine it is already unwinding —
+ * re-enqueuing it leaves a stale entry that the loop later switches into
+ * after the context is gone. Until delivered, the first error wins; once
+ * delivered, F_CANCELLED stays set and a later cancel is delivered again. */
+ if (ZEND_COROUTINE_IS_RUNNING(coroutine) || ts_from_coro(coroutine)->pending_error != NULL) {
+ if (error != NULL && transfer_error) {
+ OBJ_RELEASE(error);
+ }
+
+ return true;
+ }
+
return ts_enqueue(coroutine, error, transfer_error);
}
@@ -1561,12 +1569,6 @@ static bool ts_suspend(bool from_main, bool is_bailout)
return false;
}
- /* Cancelled: never park again, nothing will wake it. */
- if (UNEXPECTED(ZEND_COROUTINE_IS_CANCELLED(&self->coro))) {
- zend_throw_exception(ts_ce_cancellation_error, "The coroutine has been cancelled", 0);
- return false;
- }
-
ZEND_COROUTINE_SET_STATUS(&self->coro, ZEND_COROUTINE_STATUS_SUSPENDED);
self->execute_data = EG(current_execute_data);
ZEND_ASYNC_CURRENT_COROUTINE = &TSG(scheduler)->coro;
diff --git a/ext/test_scheduler/tests/018_cancel.phpt b/ext/test_scheduler/tests/018_cancel.phpt
index 52b7d6ad6689..bee6916c0e69 100644
--- a/ext/test_scheduler/tests/018_cancel.phpt
+++ b/ext/test_scheduler/tests/018_cancel.phpt
@@ -1,5 +1,5 @@
--TEST--
-test_scheduler: cancel() — catchable at the suspend point, no re-park, first cancel wins
+test_scheduler: cancel() — catchable at the suspend point, first pending cancel wins, a later one is delivered again
--EXTENSIONS--
test_scheduler
--INI--
@@ -18,12 +18,13 @@ $victim = spawn(function () {
echo "victim caught: ", $e->getMessage(), "\n";
}
- // Cancelled means done waiting: parking again is refused on the spot.
+ // Cancellation is a request: once delivered, the coroutine may park again
+ // and the next cancel reaches it there.
try {
suspend();
echo "unreachable suspend\n";
} catch (TestScheduler\CancellationError $e) {
- echo "victim cannot suspend again\n";
+ echo "victim caught again\n";
}
return "cleanup done";
@@ -31,7 +32,13 @@ $victim = spawn(function () {
spawn(function () use ($victim) {
cancel($victim);
- cancel($victim); // the second one is a no-op
+ cancel($victim); // still pending: the second one is dropped
+
+ // Runs after the victim has caught the first one and parked again.
+ spawn(function () use ($victim) {
+ echo "second cancel\n";
+ cancel($victim);
+ });
});
echo "await victim: ", await($victim), "\n";
@@ -63,7 +70,8 @@ echo "==DONE==\n";
?>
--EXPECT--
await victim: victim caught: The coroutine has been cancelled
-victim cannot suspend again
+second cancel
+victim caught again
cleanup done
unstarted: The coroutine has been cancelled
self-cancel caught
diff --git a/ext/test_scheduler/tests/070_cancelled_fiber_suspends_in_finally.phpt b/ext/test_scheduler/tests/070_cancelled_fiber_suspends_in_finally.phpt
new file mode 100644
index 000000000000..18a9fa2b8d9a
--- /dev/null
+++ b/ext/test_scheduler/tests/070_cancelled_fiber_suspends_in_finally.phpt
@@ -0,0 +1,33 @@
+--TEST--
+test_scheduler: a fiber cancelled by the scheduler may suspend in finally; the next cancel unwinds it
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+
+echo "main done\n";
+?>
+--EXPECT--
+main done
+first finally
+second finally
From c625aa49b83ed7c667dfb9cfbb879ffda91a4d8c Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:29:07 +0000
Subject: [PATCH 09/33] fibers: keep an EH_THROW window with the fiber that
opened it
EG(error_handling) and EG(exception_class) were global across a switch:
a fiber suspended inside zend_replace_error_handling(EH_THROW) turned
every warning raised in main or in another fiber into an exception of
that window's class. The switch saves both per context and resets them
before the jump, so a context entered for the first time starts outside
any window.
---
.../error-handling-window-per-fiber.phpt | 56 +++++++++++++++++++
Zend/zend_fibers.c | 16 ++++++
2 files changed, 72 insertions(+)
create mode 100644 Zend/tests/fibers/error-handling-window-per-fiber.phpt
diff --git a/Zend/tests/fibers/error-handling-window-per-fiber.phpt b/Zend/tests/fibers/error-handling-window-per-fiber.phpt
new file mode 100644
index 000000000000..0c0cfe7b16ab
--- /dev/null
+++ b/Zend/tests/fibers/error-handling-window-per-fiber.phpt
@@ -0,0 +1,56 @@
+--TEST--
+An EH_THROW error handling window stays with the fiber that opened it
+--FILE--
+ 0100644];
+ }
+
+ public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
+ {
+ $started = new Fiber(function () {
+ trigger_error("in a fiber started inside the window", E_USER_WARNING);
+ });
+ $started->start();
+
+ Fiber::suspend();
+
+ try {
+ trigger_error("inside the window after resume", E_USER_WARNING);
+ } catch (RuntimeException $exception) {
+ echo "caught: ", $exception->getMessage(), "\n";
+ }
+
+ return false;
+ }
+}
+
+stream_wrapper_register('suspending', SuspendingWrapper::class);
+
+$fiber = new Fiber(function () {
+ try {
+ new SplFileObject('suspending://file');
+ } catch (RuntimeException $exception) {
+ echo "constructor: ", get_class($exception), "\n";
+ }
+});
+
+$fiber->start();
+trigger_error("in main while the fiber is inside the window", E_USER_WARNING);
+$fiber->resume();
+trigger_error("in main after the window closed", E_USER_WARNING);
+?>
+--EXPECTF--
+Warning: in a fiber started inside the window in %s on line %d
+
+Warning: in main while the fiber is inside the window in %s on line %d
+caught: inside the window after resume
+constructor: RuntimeException
+
+Warning: in main after the window closed in %s on line %d
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index f98815b7979a..4c8c00be304e 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -110,6 +110,11 @@ typedef struct _zend_fiber_vm_state {
size_t vm_stack_page_size;
zend_execute_data *current_execute_data;
int error_reporting;
+ /* An EH_THROW window (zend_replace_error_handling) belongs to the code that
+ * opened it: left global, a fiber suspended inside one turns every warning
+ * raised elsewhere into an exception of that window's class. */
+ zend_error_handling_t error_handling;
+ zend_class_entry *exception_class;
uint32_t jit_trace_num;
JMP_BUF *bailout;
zend_fiber *active_fiber;
@@ -127,6 +132,8 @@ static zend_always_inline void zend_fiber_capture_vm_state(zend_fiber_vm_state *
state->vm_stack_page_size = EG(vm_stack_page_size);
state->current_execute_data = EG(current_execute_data);
state->error_reporting = EG(error_reporting);
+ state->error_handling = EG(error_handling);
+ state->exception_class = EG(exception_class);
state->jit_trace_num = EG(jit_trace_num);
state->bailout = EG(bailout);
state->active_fiber = EG(active_fiber);
@@ -144,6 +151,8 @@ static zend_always_inline void zend_fiber_restore_vm_state(zend_fiber_vm_state *
EG(vm_stack_page_size) = state->vm_stack_page_size;
EG(current_execute_data) = state->current_execute_data;
EG(error_reporting) = state->error_reporting;
+ EG(error_handling) = state->error_handling;
+ EG(exception_class) = state->exception_class;
EG(jit_trace_num) = state->jit_trace_num;
EG(bailout) = state->bailout;
EG(active_fiber) = state->active_fiber;
@@ -496,6 +505,13 @@ ZEND_API void zend_fiber_switch_context(zend_fiber_transfer *transfer)
zend_fiber_capture_vm_state(&state);
+ /* Leave our EH_THROW window behind: it belongs to whoever opened it. A context
+ * resumed by this switch restores its own below, in its own frame of this
+ * function; a context entered for the first time never reaches that restore,
+ * and without this would run inside a window it never opened. */
+ EG(error_handling) = EH_NORMAL;
+ EG(exception_class) = NULL;
+
to->status = ZEND_FIBER_STATUS_RUNNING;
if (EXPECTED(from->status == ZEND_FIBER_STATUS_RUNNING)) {
From 1d84327c5b9acc47d2b86da30dd38cfa0bcb9a5c Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:30:53 +0000
Subject: [PATCH 10/33] async: refuse to enqueue a finished coroutine
The enqueue slot did not say what happens to a FINISHED coroutine, and
test_scheduler accepted it as a no-op. A finished coroutine cannot run
again, so the contract is false with an Error, as the core's callers
already expect from Fiber::resume() and Fiber::throw(). No core caller
reaches the case from PHP, so it has no test.
---
Zend/zend_async_API.h | 4 +++-
ext/test_scheduler/test_scheduler.c | 4 ++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 2599ebe0a97b..a2c2f4ae0557 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -257,7 +257,9 @@ typedef zend_coroutine_t *(*zend_async_gc_new_coroutine_t)(void);
* Enqueuing a fresh coroutine and resuming a suspended one are the same
* operation. A non-NULL `error` is thrown at the suspension point when the
* coroutine runs — how cancellation and IO/timeout failures reach waiting
- * code; transfer_error passes ownership of the reference. */
+ * code; transfer_error passes ownership of the reference. A FINISHED
+ * coroutine cannot run again: the call returns false with an Error thrown,
+ * and a transferred `error` is released. */
typedef bool (*zend_async_enqueue_coroutine_t)(
zend_coroutine_t *coroutine, zend_object *error, bool transfer_error);
/* Yield the current coroutine (-> STATUS_SUSPENDED) and give control to the
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index 823c17abff96..caa40e53f07e 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -1384,13 +1384,13 @@ static bool ts_enqueue(zend_coroutine_t *coroutine, zend_object *error, bool tra
{
ts_coroutine_t *ts = ts_from_coro(coroutine);
- /* Finished: nothing to run, the enqueue is a no-op. */
if (UNEXPECTED(ZEND_COROUTINE_IS_FINISHED(coroutine))) {
if (error != NULL && transfer_error) {
OBJ_RELEASE(error);
}
- return true;
+ zend_throw_error(NULL, "Cannot enqueue a finished coroutine");
+ return false;
}
/* Thrown at the suspension point when the coroutine runs. */
From 38eadbb9ce9de0bcf869725cb3e976ebaf8f50aa Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:34:00 +0000
Subject: [PATCH 11/33] gc: start the GC coroutine without waiting where
switching is blocked
Under the scheduler, gc_collect_cycles() awaited the GC coroutine even in
a tick function, a signal handler or the scheduler's own work, where no
coroutine may switch. It now only starts the run, which collects when the
scheduler next picks the GC coroutine, and returns 0. A run that was
started but not awaited no longer feeds that 0 to the threshold
heuristic, which raised the threshold by a step each time.
---
Zend/zend_gc.c | 30 ++++++++-
.../071_gc_where_switching_is_blocked.phpt | 65 +++++++++++++++++++
2 files changed, 93 insertions(+), 2 deletions(-)
create mode 100644 ext/test_scheduler/tests/071_gc_where_switching_is_blocked.phpt
diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c
index 1878319bda33..90ca196b6f12 100644
--- a/Zend/zend_gc.c
+++ b/Zend/zend_gc.c
@@ -302,6 +302,9 @@ typedef struct _zend_gc_globals {
uint32_t dtor_pending;
/* Result of the last coroutine-run collection, returned to the waiters. */
int gc_collected;
+ /* The last gc_collect_cycles() call only started the GC coroutine: its 0
+ * counts nothing, so the threshold heuristic must not read it. */
+ bool run_deferred;
#if GC_BENCH
uint32_t root_buf_length;
@@ -549,6 +552,7 @@ static void gc_globals_ctor_ex(zend_gc_globals *gc_globals)
gc_globals->microtask = NULL;
gc_globals->dtor_pending = 0;
gc_globals->gc_collected = 0;
+ gc_globals->run_deferred = false;
#if GC_BENCH
gc_globals->root_buf_length = 0;
@@ -712,7 +716,12 @@ static zend_never_inline void ZEND_FASTCALL gc_possible_root_when_full(zend_refc
if (GC_G(gc_enabled) && !GC_G(gc_active)) {
GC_ADDREF(ref);
- gc_adjust_threshold(gc_collect_cycles());
+ GC_G(run_deferred) = false;
+ const int count = gc_collect_cycles();
+ if (!GC_G(run_deferred)) {
+ gc_adjust_threshold(count);
+ }
+
if (UNEXPECTED(GC_DELREF(ref) == 0)) {
rc_dtor_func(ref);
return;
@@ -2208,6 +2217,18 @@ ZEND_API int zend_gc_collect_cycles(void)
return 0;
}
+ /* This stack cannot wait here (a tick function, a signal handler, the
+ * scheduler's own work): start the run without waiting for it. It
+ * collects when the scheduler next picks the GC coroutine. */
+ if (UNEXPECTED(zend_fiber_switch_blocked() || ZEND_ASYNC_IN_SCHEDULER_CONTEXT)) {
+ if (GC_G(gc_coroutine) == NULL) {
+ new_gc_coroutine();
+ }
+
+ GC_G(run_deferred) = true;
+ return 0;
+ }
+
/* The run executes on the GC coroutine and cannot see this stack:
* shield the caller's live TMPVARs from it for the duration. */
if (GC_G(num_roots)) {
@@ -2231,7 +2252,12 @@ ZEND_API int zend_gc_collect_cycles(void)
zend_gc_check_root_tmpvars();
GC_G(gc_active) = was_active;
- return awaited ? GC_G(gc_collected) : 0;
+ if (UNEXPECTED(!awaited)) {
+ GC_G(run_deferred) = true;
+ return 0;
+ }
+
+ return GC_G(gc_collected);
}
int total_count = 0;
diff --git a/ext/test_scheduler/tests/071_gc_where_switching_is_blocked.phpt b/ext/test_scheduler/tests/071_gc_where_switching_is_blocked.phpt
new file mode 100644
index 000000000000..4e8fac45ab3f
--- /dev/null
+++ b/ext/test_scheduler/tests/071_gc_where_switching_is_blocked.phpt
@@ -0,0 +1,65 @@
+--TEST--
+test_scheduler: GC where switching is blocked starts its coroutine and returns at once
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $node;
+}
+
+// Live objects whose refcount drops to non-zero: each one becomes a possible
+// root that is not garbage, so an automatic run collects nothing.
+function fill_roots(array $objects): void
+{
+ foreach ($objects as $object) {
+ $copy = $object;
+ unset($copy);
+ }
+}
+
+$objects = [];
+for ($i = 0; $i < 12000; $i++) {
+ $objects[] = new stdClass;
+}
+
+$threshold = gc_status()['threshold'];
+$ticked = false;
+
+register_tick_function(function () use (&$ticked, $objects) {
+ if ($ticked) {
+ return;
+ }
+
+ $ticked = true;
+ make_cycle();
+ var_dump(gc_collect_cycles());
+ fill_roots($objects);
+ echo "tick done\n";
+});
+
+echo "after tick\n";
+var_dump(gc_status()['threshold'] === $threshold);
+// The deferred run collects the cycle when main ends and the queue drains.
+?>
+--EXPECT--
+int(0)
+tick done
+after tick
+bool(true)
+destructor
From 4c39d6e45eb0278e69c9e8abac7dfa5b2e2f9f4b Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:37:36 +0000
Subject: [PATCH 12/33] gc: clear the GC coroutine pointers when a bailout cuts
a run off
GC_G(gc_coroutine) was cleared only at the end of the run's body, which a
bailout skips: a shutdown function's gc_collect_cycles() then awaited the
dead coroutine and returned 0 without collecting. A finish handler now
clears it however the run ends and drops the iterator microtask on a
bailout, and gc_reset() clears the async fields, so none of them reaches
the next request (FPM, ZTS); that half has no test in a CLI run.
---
Zend/zend_gc.c | 35 ++++++++++-
.../tests/072_gc_after_bailout.phpt | 62 +++++++++++++++++++
2 files changed, 96 insertions(+), 1 deletion(-)
create mode 100644 ext/test_scheduler/tests/072_gc_after_bailout.phpt
diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c
index 90ca196b6f12..75ff52d2185d 100644
--- a/Zend/zend_gc.c
+++ b/Zend/zend_gc.c
@@ -612,6 +612,14 @@ void gc_reset(void)
#endif
}
+ /* A bailout can end the request before the GC coroutines finish, and
+ * their memory goes with the request: none of these may reach the next. */
+ GC_G(gc_coroutine) = NULL;
+ GC_G(dtor_coroutine) = NULL;
+ GC_G(microtask) = NULL;
+ GC_G(dtor_pending) = 0;
+ GC_G(run_deferred) = false;
+
GC_G(activated_at) = zend_hrtime();
}
@@ -2187,6 +2195,27 @@ static void zend_gc_coroutine(void)
GC_TRACE("GC coroutine finished");
}
+/* Clears GC_G(gc_coroutine) however the run ends. A bailout skips the tail of
+ * zend_gc_coroutine(), and the next gc_collect_cycles() of the request (a
+ * shutdown function's) would await the stale coroutine instead of collecting. */
+static bool gc_coroutine_finish_handler(
+ zend_coroutine_t *coroutine, zend_coroutine_t *waiter, void *data, const bool is_bailout)
+{
+ (void) waiter;
+ (void) data;
+
+ if (GC_G(gc_coroutine) == coroutine) {
+ GC_G(gc_coroutine) = NULL;
+ }
+
+ /* No iterator resumes after a bailout: drop the hand-off they armed. */
+ if (is_bailout) {
+ gc_disarm_iterator_microtask();
+ }
+
+ return false;
+}
+
static zend_always_inline zend_coroutine_t *new_gc_coroutine(void)
{
zend_coroutine_t *coroutine = ZEND_ASYNC_GC_NEW_COROUTINE();
@@ -2198,7 +2227,11 @@ static zend_always_inline zend_coroutine_t *new_gc_coroutine(void)
coroutine->internal_entry = zend_gc_coroutine;
GC_G(gc_coroutine) = coroutine;
- if (UNEXPECTED(!ZEND_ASYNC_ENQUEUE_COROUTINE(coroutine))) {
+ const uint32_t handler_id = ZEND_ASYNC_ADD_FINISH_HANDLER(
+ coroutine, gc_coroutine_finish_handler, NULL, NULL);
+
+ if (UNEXPECTED(handler_id == 0 || !ZEND_ASYNC_ENQUEUE_COROUTINE(coroutine))) {
+ ZEND_ASYNC_REMOVE_FINISH_HANDLER(coroutine, handler_id);
GC_G(gc_coroutine) = NULL;
return NULL;
}
diff --git a/ext/test_scheduler/tests/072_gc_after_bailout.phpt b/ext/test_scheduler/tests/072_gc_after_bailout.phpt
new file mode 100644
index 000000000000..5b400525219d
--- /dev/null
+++ b/ext/test_scheduler/tests/072_gc_after_bailout.phpt
@@ -0,0 +1,62 @@
+--TEST--
+test_scheduler: a GC run cut off by a bailout does not leave its coroutine behind
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+name}\n";
+ }
+}
+
+function make_cycle(string $name): void
+{
+ $node = new Node($name);
+ $node->self = $node;
+}
+
+// After the bailout this run must collect, not await the coroutine the fatal
+// error cut off. The fatal error marked the tick's node destructed, so it is
+// freed without a destructor call.
+register_shutdown_function(function () {
+ echo "shutdown function\n";
+ make_cycle('shutdown');
+ var_dump(gc_collect_cycles());
+});
+
+// Switching is blocked in a tick function: the GC coroutine is only queued,
+// and the fatal error below comes before it runs.
+$ticked = false;
+register_tick_function(function () use (&$ticked) {
+ if ($ticked) {
+ return;
+ }
+
+ $ticked = true;
+ make_cycle('tick');
+ var_dump(gc_collect_cycles());
+});
+
+echo "fatal next\n";
+trigger_error('fatal', E_USER_ERROR);
+?>
+--EXPECTF--
+int(0)
+fatal next
+
+Deprecated: Passing E_USER_ERROR to trigger_error() is deprecated since 8.4, throw an exception or call exit with a string message instead in %s on line %d
+
+Fatal error: fatal in %s on line %d
+shutdown function
+int(1)
+destructor shutdown
From 101407b6f00ea9befcdcbcefdb0d3f8a7acd358e Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:39:22 +0000
Subject: [PATCH 13/33] fibers: survive a shutdown slot that clears the exit
exception
After exit() in a fiber coroutine the core called the shutdown slot and
then took a reference to EG(exception) for the waiting caller. A scheduler
that consumes the unwind_exit there (the reference ext/async does) left
it NULL, and the fiber's caller crashed. A cleared exception now ends the
fiber without an error and wakes the caller. test_scheduler keeps the
exception, so there is no phpt; checked by hand with a shutdown slot
that clears it (segfault before, clean exit after).
---
Zend/zend_async_API.h | 3 +++
Zend/zend_fibers.c | 10 ++++++++++
2 files changed, 13 insertions(+)
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index a2c2f4ae0557..0aba65a31e38 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -280,6 +280,9 @@ typedef bool (*zend_async_cancel_t)(
* Returning NULL is a failure: without a main coroutine there is no flow to
* run the script in. */
typedef zend_coroutine_t *(*zend_async_scheduler_launch_t)(void);
+/* exit() unwinds a fiber coroutine: the scheduler decides what happens to the
+ * rest of the application. The unwind_exit stays in EG(exception) for the
+ * scheduler to keep or clear; a cleared one ends the fiber without an error. */
typedef bool (*zend_async_shutdown_t)(void);
typedef zend_class_entry *(*zend_async_get_class_ce_t)(zend_async_class type);
/* Run fn(arg) on the main coroutine's OS-thread stack (FFI/JNI etc.). */
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index 4c8c00be304e..9ee52a12b444 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -873,6 +873,16 @@ static void zend_fiber_coroutine_entry(void)
* exit is a disposal unwind and must not shut anything down. */
if (zend_is_unwind_exit(EG(exception))) {
ZEND_ASYNC_SHUTDOWN();
+
+ /* The scheduler took the exit over: the body ended, nothing is
+ * thrown to the caller. */
+ if (EG(exception) == NULL) {
+ if (caller != NULL) {
+ ZEND_ASYNC_ENQUEUE_COROUTINE(caller);
+ }
+
+ return;
+ }
}
if (fiber != NULL) {
From c267f4642a5cad239c7b8004c1ac653df3c65a3e Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:41:13 +0000
Subject: [PATCH 14/33] async: clear the current and main coroutine when the
request deactivates
ZEND_ASYNC_DEACTIVATE only switched the state off, so output handlers,
RSHUTDOWN and the next request's RINIT still saw the request's current
and main coroutine after the scheduler had torn them down; an internal
context lookup with no coroutine given read the freed one.
---
Zend/zend_async_API.h | 9 ++++++-
.../073_no_coroutine_after_deactivation.phpt | 24 +++++++++++++++++++
2 files changed, 32 insertions(+), 1 deletion(-)
create mode 100644 ext/test_scheduler/tests/073_no_coroutine_after_deactivation.phpt
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 0aba65a31e38..6c161f5ebae7 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -703,7 +703,14 @@ END_EXTERN_C()
#define ZEND_ASYNC_IS_READY (ZEND_ASYNC_G(state) == ZEND_ASYNC_READY)
#define ZEND_ASYNC_ACTIVATE ZEND_ASYNC_G(state) = ZEND_ASYNC_ACTIVE
#define ZEND_ASYNC_INITIALIZE ZEND_ASYNC_G(state) = ZEND_ASYNC_READY
-#define ZEND_ASYNC_DEACTIVATE ZEND_ASYNC_G(state) = ZEND_ASYNC_OFF
+/* The coroutines go with the request: code that runs after this (output
+ * handlers, RSHUTDOWN, the next request's RINIT) sees no current or main one. */
+#define ZEND_ASYNC_DEACTIVATE \
+ do { \
+ ZEND_ASYNC_G(state) = ZEND_ASYNC_OFF; \
+ ZEND_ASYNC_G(coroutine) = NULL; \
+ ZEND_ASYNC_G(main_coroutine) = NULL; \
+ } while (0)
#define ZEND_ASYNC_CURRENT_COROUTINE ZEND_ASYNC_G(coroutine)
#define ZEND_ASYNC_MAIN_COROUTINE ZEND_ASYNC_G(main_coroutine)
diff --git a/ext/test_scheduler/tests/073_no_coroutine_after_deactivation.phpt b/ext/test_scheduler/tests/073_no_coroutine_after_deactivation.phpt
new file mode 100644
index 000000000000..529f11fb9e9f
--- /dev/null
+++ b/ext/test_scheduler/tests/073_no_coroutine_after_deactivation.phpt
@@ -0,0 +1,24 @@
+--TEST--
+test_scheduler: no current coroutine once the request deactivates async
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main
+current in phase 5: TestScheduler\Coroutine
+current in phase 8: null
From 2b64c1f1c09854d9fad1234f98dfcd04bd846e64 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:43:37 +0000
Subject: [PATCH 15/33] async: document the parked frame, the await refusal and
the flag bits
The await slot gains its third false case: the scheduler's own work cannot
wait, so the slot returns false without an exception and the caller goes
on; test_scheduler follows it and keeps the Error for userland await().
The execute_data slot speaks of a parked coroutine, which includes one
that yielded and is QUEUED. CREATED means allocated and not yet enqueued,
and bits 16-31 of the flags belong to the scheduler.
---
Zend/zend_async_API.h | 23 ++++++++++++++---------
ext/test_scheduler/test_scheduler.c | 12 +++++++++++-
2 files changed, 25 insertions(+), 10 deletions(-)
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 6c161f5ebae7..1b713439e6a1 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -94,7 +94,7 @@ typedef bool (*zend_coroutine_finish_handler_fn)(
* scheduler.
*/
typedef enum {
- ZEND_COROUTINE_STATUS_CREATED = 0, /* spawned, never executed */
+ ZEND_COROUTINE_STATUS_CREATED = 0, /* allocated, not yet enqueued */
ZEND_COROUTINE_STATUS_QUEUED, /* ready, waiting in the run queue */
ZEND_COROUTINE_STATUS_RUNNING, /* currently executing */
ZEND_COROUTINE_STATUS_SUSPENDED, /* waiting; see awaiting_info */
@@ -103,7 +103,8 @@ typedef enum {
struct _zend_coroutine_s {
/* Bits 0-3: zend_coroutine_status (the scheduler is the only writer);
- * bits 4+: ZEND_COROUTINE_F_* modifiers. */
+ * bits 4-15: the core's ZEND_COROUTINE_F_* modifiers; bits 16-31 belong to
+ * the scheduler. */
uint32_t flags;
/* Offset of the wrapping zend_object within the allocation, when the
* coroutine is embedded in one (single-allocation pattern: the object
@@ -338,7 +339,9 @@ typedef bool (*zend_async_defer_t)(zend_async_microtask_t *task);
* waiter bookkeeping (it lives on the awaited coroutine), may wake the waiter
* with a direct switch instead of the run queue, and marks the outcome as
* observed. False when the wait was aborted — a cancellation delivered to the
- * waiter, or misuse (no current coroutine, awaiting itself). */
+ * waiter, or misuse (no current coroutine, awaiting itself) — or when the wait
+ * is not possible here (the scheduler is running its own work): false without
+ * an exception, and the caller does not wait. */
typedef bool (*zend_async_coroutine_await_t)(zend_coroutine_t *coroutine);
/*
@@ -368,11 +371,13 @@ typedef zend_coroutine_t *(*zend_async_coroutine_from_object_t)(zend_object *obj
*/
typedef zend_coroutine_t *(*zend_async_intercept_fiber_t)(zend_fiber *fiber);
-/* The frame a suspended coroutine is parked in, or NULL when it is not
- * suspended (or the provider does not track it). The stack a coroutine runs
- * on belongs to the scheduler, so this is the only way for the engine to
- * reach it — the garbage collector needs it to see the variables alive on
- * that stack, and a backtrace needs it to walk past the coroutine. */
+/* The frame a parked coroutine waits in, or NULL when it is not parked (or the
+ * provider does not track it). Parked means started, not running and not
+ * finished: a coroutine that yielded is QUEUED and still has a frame. The
+ * stack a coroutine runs on belongs to the scheduler, so this is the only way
+ * for the engine to reach it — the garbage collector needs it to see the
+ * variables alive on that stack, and a backtrace needs it to walk past the
+ * coroutine. */
typedef zend_execute_data *(*zend_async_coroutine_execute_data_t)(zend_coroutine_t *coroutine);
/*
@@ -606,7 +611,7 @@ END_EXTERN_C()
#define ZEND_ASYNC_CALL_ON_MAIN_STACK(fn, arg) zend_async_call_on_main_stack_fn((fn), (arg))
#define ZEND_ASYNC_DEFER(task) zend_async_defer_fn(task)
-/* The frame a suspended coroutine is parked in, or NULL. */
+/* The frame a parked coroutine waits in, or NULL. */
#define ZEND_ASYNC_COROUTINE_EXECUTE_DATA(coroutine) \
(zend_async_coroutine_execute_data_fn != NULL \
? zend_async_coroutine_execute_data_fn(coroutine) \
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index caa40e53f07e..c9178aa9a877 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -531,11 +531,16 @@ static bool ts_await(zend_coroutine_t *coroutine)
ts_coroutine_t *target = ts_from_coro(coroutine);
zend_coroutine_t *self = ZEND_ASYNC_CURRENT_COROUTINE;
- if (UNEXPECTED(self == NULL || ZEND_ASYNC_IN_SCHEDULER_CONTEXT)) {
+ if (UNEXPECTED(self == NULL)) {
zend_throw_error(NULL, "await() requires a running coroutine");
return false;
}
+ /* The scheduler's own work cannot wait: the caller goes on without it. */
+ if (UNEXPECTED(ZEND_ASYNC_IN_SCHEDULER_CONTEXT)) {
+ return false;
+ }
+
if (UNEXPECTED(self == coroutine)) {
zend_throw_error(NULL, "Cannot await a coroutine from within itself");
return false;
@@ -1732,6 +1737,11 @@ PHP_FUNCTION(TestScheduler_await)
ts_coroutine_t *target = ts_from_obj(object);
+ if (UNEXPECTED(ZEND_ASYNC_IN_SCHEDULER_CONTEXT)) {
+ zend_throw_error(NULL, "await() requires a running coroutine");
+ RETURN_THROWS();
+ }
+
if (!ts_await(&target->coro)) {
RETURN_THROWS();
}
From 5c47924e3b56a0b791d08149c1a307c8f3b9281c Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:45:23 +0000
Subject: [PATCH 16/33] fibers: refuse Fiber methods while the scheduler runs
its own work
A destructor run by the scheduler (releasing a finished coroutine) could
call Fiber::start(), resume() or throw() and switch the scheduler out of
its own loop: the fiber never ran and the request ended with a stray
GracefulExit. The methods now throw FiberError there, as they do where
switching is blocked, before they change any state.
---
Zend/zend_fibers.c | 18 ++++--
...74_fiber_methods_in_scheduler_context.phpt | 56 +++++++++++++++++++
2 files changed, 69 insertions(+), 5 deletions(-)
create mode 100644 ext/test_scheduler/tests/074_fiber_methods_in_scheduler_context.phpt
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index 9ee52a12b444..b55553c73b6c 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -423,6 +423,14 @@ ZEND_API bool zend_fiber_switch_blocked(void)
return zend_fiber_switch_blocking;
}
+/* The Fiber methods switch contexts, which is refused where switching is
+ * blocked and while the scheduler runs its own work: there is no coroutine
+ * to park, and the scheduler would be switched out of its own loop. */
+static zend_always_inline bool zend_fiber_switch_refused(void)
+{
+ return zend_fiber_switch_blocked() || (ZEND_ASYNC_ON && ZEND_ASYNC_IN_SCHEDULER_CONTEXT);
+}
+
ZEND_API zend_result zend_fiber_init_context(zend_fiber_context *context, void *kind, zend_fiber_coroutine coroutine, size_t stack_size)
{
context->stack = zend_fiber_stack_allocate(stack_size);
@@ -1338,7 +1346,7 @@ ZEND_METHOD(Fiber, start)
Z_PARAM_VARIADIC_WITH_NAMED(fiber->fci.params, fiber->fci.param_count, fiber->fci.named_params);
ZEND_PARSE_PARAMETERS_END();
- if (UNEXPECTED(zend_fiber_switch_blocked())) {
+ if (UNEXPECTED(zend_fiber_switch_refused())) {
zend_throw_error(zend_ce_fiber_error, "Cannot switch fibers in current execution context");
RETURN_THROWS();
}
@@ -1394,7 +1402,7 @@ ZEND_METHOD(Fiber, suspend)
RETURN_THROWS();
}
- if (UNEXPECTED(zend_fiber_switch_blocked())) {
+ if (UNEXPECTED(zend_fiber_switch_refused())) {
zend_throw_error(zend_ce_fiber_error, "Cannot switch fibers in current execution context");
RETURN_THROWS();
}
@@ -1421,7 +1429,7 @@ ZEND_METHOD(Fiber, suspend)
RETURN_THROWS();
}
- if (UNEXPECTED(zend_fiber_switch_blocked())) {
+ if (UNEXPECTED(zend_fiber_switch_refused())) {
zend_throw_error(zend_ce_fiber_error, "Cannot switch fibers in current execution context");
RETURN_THROWS();
}
@@ -1445,7 +1453,7 @@ ZEND_METHOD(Fiber, resume)
Z_PARAM_ZVAL(value);
ZEND_PARSE_PARAMETERS_END();
- if (UNEXPECTED(zend_fiber_switch_blocked())) {
+ if (UNEXPECTED(zend_fiber_switch_refused())) {
zend_throw_error(zend_ce_fiber_error, "Cannot switch fibers in current execution context");
RETURN_THROWS();
}
@@ -1488,7 +1496,7 @@ ZEND_METHOD(Fiber, throw)
Z_PARAM_OBJECT_OF_CLASS(exception, zend_ce_throwable)
ZEND_PARSE_PARAMETERS_END();
- if (UNEXPECTED(zend_fiber_switch_blocked())) {
+ if (UNEXPECTED(zend_fiber_switch_refused())) {
zend_throw_error(zend_ce_fiber_error, "Cannot switch fibers in current execution context");
RETURN_THROWS();
}
diff --git a/ext/test_scheduler/tests/074_fiber_methods_in_scheduler_context.phpt b/ext/test_scheduler/tests/074_fiber_methods_in_scheduler_context.phpt
new file mode 100644
index 000000000000..e605398d050a
--- /dev/null
+++ b/ext/test_scheduler/tests/074_fiber_methods_in_scheduler_context.phpt
@@ -0,0 +1,56 @@
+--TEST--
+test_scheduler: Fiber methods refuse to switch while the scheduler runs its own work
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage(), "\n";
+ }
+}
+
+$suspended = new Fiber(function () {
+ Fiber::suspend();
+ echo "unreachable\n";
+});
+$suspended->start();
+
+// Destroyed when the scheduler releases the finished coroutine, so the
+// destructor runs in the scheduler context.
+class Probe {
+ public function __destruct()
+ {
+ global $suspended;
+
+ attempt("start", function () {
+ (new Fiber(function () {
+ echo "unreachable\n";
+ }))->start();
+ });
+ attempt("resume", fn () => $suspended->resume());
+ attempt("throw", fn () => $suspended->throw(new Exception("unreachable")));
+ attempt("suspend", fn () => Fiber::suspend());
+ var_dump($suspended->isSuspended());
+ }
+}
+
+TestScheduler\spawn(function () {
+ return new Probe;
+});
+
+echo "main done\n";
+?>
+--EXPECT--
+main done
+start: Cannot switch fibers in current execution context
+resume: Cannot switch fibers in current execution context
+throw: Cannot switch fibers in current execution context
+suspend: Cannot suspend outside of a fiber
+bool(true)
From 2aee763aeeddf265fb8c2c15be604af5f5d00f98 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:48:43 +0000
Subject: [PATCH 17/33] fibers: call the switch observer only when something
observes
zend_fiber_switch_context() called zend_observer_fiber_switch_notify() on
every switch, and under a scheduler every await, suspend and resume is a
switch. The call now sits behind ZEND_OBSERVER_FIBER_SWITCH_ENABLED (fcall
observers or a registered switch handler), as in the TrueAsync fork. The
zend_test observer tests pass before and after and fail when the call is
dropped entirely.
---
Zend/zend_fibers.c | 4 +++-
Zend/zend_observer.c | 2 +-
Zend/zend_observer.h | 6 ++++++
3 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index b55553c73b6c..4ebb8abd8556 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -509,7 +509,9 @@ ZEND_API void zend_fiber_switch_context(zend_fiber_transfer *transfer)
))
) && "Error transfer requires a throwable value");
- zend_observer_fiber_switch_notify(from, to);
+ if (UNEXPECTED(ZEND_OBSERVER_FIBER_SWITCH_ENABLED)) {
+ zend_observer_fiber_switch_notify(from, to);
+ }
zend_fiber_capture_vm_state(&state);
diff --git a/Zend/zend_observer.c b/Zend/zend_observer.c
index 1cb4373f4c07..af95c32e3d58 100644
--- a/Zend/zend_observer.c
+++ b/Zend/zend_observer.c
@@ -30,7 +30,7 @@ static zend_llist zend_observer_function_declared_callbacks;
static zend_llist zend_observer_class_linked_callbacks;
static zend_llist zend_observer_error_callbacks;
static zend_llist zend_observer_fiber_init;
-static zend_llist zend_observer_fiber_switch;
+ZEND_API zend_llist zend_observer_fiber_switch;
static zend_llist zend_observer_fiber_destroy;
int zend_observer_fcall_op_array_extension;
diff --git a/Zend/zend_observer.h b/Zend/zend_observer.h
index bcfba35c0658..fca2a30d4c0a 100644
--- a/Zend/zend_observer.h
+++ b/Zend/zend_observer.h
@@ -46,6 +46,12 @@ static zend_always_inline int ZEND_OBSERVER_HANDLE(const zend_function *function
/* Omit zend_observer_fcall_internal_function_extension check, they are set at the same time. */
#define ZEND_OBSERVER_ENABLED (zend_observer_fcall_op_array_extension != -1)
+extern ZEND_API zend_llist zend_observer_fiber_switch;
+/* A fiber switch has something to notify: fcall observers whose observed
+ * frame moves with the context, or a registered switch handler. */
+#define ZEND_OBSERVER_FIBER_SWITCH_ENABLED \
+ (ZEND_OBSERVER_ENABLED || zend_observer_fiber_switch.head != NULL)
+
typedef void (*zend_observer_fcall_begin_handler)(zend_execute_data *execute_data);
typedef void (*zend_observer_fcall_end_handler)(zend_execute_data *execute_data, zval *retval);
From 565f515df1634755feae2fa4389c9165a1c2114f Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Fri, 2 Oct 2026 13:34:49 +0000
Subject: [PATCH 18/33] fibers: read the stack before a cleanup that may free
the context
A scheduler that allocates its fiber contexts apart from their stacks
frees the context in its cleanup; zend_fiber_destroy_context then read
context->stack from freed memory (heap-use-after-free under ASAN on the
first spawned coroutine of true_async). The stack is now read first, as
TrueAsync's core does.
---
Zend/zend_fibers.c | 6 +++++-
Zend/zend_fibers.h | 2 +-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index 4ebb8abd8556..9f92b00b345f 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -482,11 +482,15 @@ ZEND_API void zend_fiber_destroy_context(zend_fiber_context *context)
{
zend_observer_fiber_destroy_notify(context);
+ /* Read before the cleanup, which may free the context: a scheduler allocates its contexts
+ * apart from their stacks. */
+ zend_fiber_stack *stack = context->stack;
+
if (context->cleanup) {
context->cleanup(context);
}
- zend_fiber_stack_free(context->stack);
+ zend_fiber_stack_free(stack);
}
ZEND_API void zend_fiber_switch_context(zend_fiber_transfer *transfer)
diff --git a/Zend/zend_fibers.h b/Zend/zend_fibers.h
index e13ffb3f3643..4e00f3921d25 100644
--- a/Zend/zend_fibers.h
+++ b/Zend/zend_fibers.h
@@ -82,7 +82,7 @@ struct _zend_fiber_context {
/* Entrypoint function of the fiber. */
zend_fiber_coroutine function;
- /* Cleanup function for fiber. */
+ /* Cleanup function for fiber; it may free the context. */
zend_fiber_clean cleanup;
/* Assigned C stack. */
From 2cb30e538e429cde0154b54ec25c9903d9f462c9 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:47:14 +0000
Subject: [PATCH 19/33] fibers: hold the object a class-string callable
resolved to
Fiber::__construct() and ZEND_ASYNC_FCALL_DEFINE copied the callable's
fci without a reference on fci.object: the $this that [A::class, 'm']
resolves to inside a method of A. When A died before start(), the fiber
called the method on freed memory (reproduced on master and PHP 8.3
without a scheduler). Both now hold it, release it with the callable
and report it to the GC. ZEND_ASYNC_API_VERSION becomes 20261003: a
provider's get_gc reports the new reference.
---
...fiber-callable-object-outlives-caller.phpt | 33 ++++++++++++++
Zend/zend_async_API.h | 15 +++++--
Zend/zend_fibers.c | 41 ++++++++++++++++--
ext/test_scheduler/test_scheduler.c | 4 ++
.../075_callable_object_outlives_caller.phpt | 43 +++++++++++++++++++
5 files changed, 130 insertions(+), 6 deletions(-)
create mode 100644 Zend/tests/fibers/fiber-callable-object-outlives-caller.phpt
create mode 100644 ext/test_scheduler/tests/075_callable_object_outlives_caller.phpt
diff --git a/Zend/tests/fibers/fiber-callable-object-outlives-caller.phpt b/Zend/tests/fibers/fiber-callable-object-outlives-caller.phpt
new file mode 100644
index 000000000000..f4d11565d3b9
--- /dev/null
+++ b/Zend/tests/fibers/fiber-callable-object-outlives-caller.phpt
@@ -0,0 +1,33 @@
+--TEST--
+Fiber keeps the object a class-string callable resolved to: $this of the creating method outlives it
+--FILE--
+label}\n";
+ }
+
+ public function makeFiber(): Fiber {
+ return new Fiber([Target::class, 'method']);
+ }
+
+ public function __destruct() {
+ echo "released\n";
+ }
+}
+
+// The only reference to the Target is the creating call's $this.
+$fiber = (new Target)->makeFiber();
+$filler = array_fill(0, 100, new stdClass());
+$fiber->start();
+echo "started\n";
+unset($fiber);
+echo "done\n";
+?>
+--EXPECT--
+method: kept
+released
+started
+done
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 1b713439e6a1..298fc74f1dd1 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -208,7 +208,9 @@ struct _zend_coroutine_s {
/**
* Build a zend_fcall_t from PHP function parameters
- * (Z_PARAM_FUNC + Z_PARAM_VARIADIC_WITH_NAMED).
+ * (Z_PARAM_FUNC + Z_PARAM_VARIADIC_WITH_NAMED). It holds a reference on each
+ * value it copies: function_name, params, named_params and fci.object. A
+ * provider's get_gc reports all four.
*/
#define ZEND_ASYNC_FCALL_DEFINE(_fcall_var, _src_fci, _src_fcc, _src_args, _src_args_count, _src_named_args) \
zend_fcall_t *_fcall_var = ecalloc(1, sizeof(zend_fcall_t)); \
@@ -225,7 +227,11 @@ struct _zend_coroutine_s {
_fcall_var->fci.named_params = _src_named_args; \
GC_ADDREF(_src_named_args); \
} \
- Z_TRY_ADDREF(_fcall_var->fci.function_name);
+ Z_TRY_ADDREF(_fcall_var->fci.function_name); \
+ /* The object a class-string callable resolved to is in no zval of its own. */ \
+ if (_fcall_var->fci.object != NULL) { \
+ GC_ADDREF(_fcall_var->fci.object); \
+ }
/* The inverse of ZEND_ASYNC_FCALL_DEFINE: releases everything the macro
* copied and frees the zend_fcall_t. Safe on NULL. */
@@ -238,6 +244,9 @@ struct _zend_coroutine_s {
zend_array_release(_fcall->fci.named_params); \
} \
zval_ptr_dtor(&_fcall->fci.function_name); \
+ if (_fcall->fci.object != NULL) { \
+ OBJ_RELEASE(_fcall->fci.object); \
+ } \
efree(_fcall); \
} \
} while (0)
@@ -413,7 +422,7 @@ typedef zend_array *(*zend_async_coroutine_get_awaiting_info_t)(zend_coroutine_t
/* Date of the last incompatible change to this API: a changed slot signature
* or meaning, a reordered field. Appending a slot does not change it. */
-#define ZEND_ASYNC_API_VERSION 20261001
+#define ZEND_ASYNC_API_VERSION 20261003
/**
* Scheduler API bundle. A provider fills the struct and calls
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index 9f92b00b345f..57033020bd2a 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -640,6 +640,21 @@ static void zend_fiber_cleanup(zend_fiber_context *context)
fiber->caller = NULL;
}
+/* Releases the callable Fiber::__construct() took: its name and the object it
+ * resolved to. Safe to call twice. */
+static void zend_fiber_release_callable(zend_fiber *fiber)
+{
+ zval_ptr_dtor(&fiber->fci.function_name);
+ ZVAL_UNDEF(&fiber->fci.function_name);
+
+ if (fiber->fci.object != NULL) {
+ zend_object *object = fiber->fci.object;
+ fiber->fci.object = NULL;
+ fiber->fci_cache.object = NULL;
+ OBJ_RELEASE(object);
+ }
+}
+
static ZEND_STACK_ALIGNED void zend_fiber_execute(zend_fiber_transfer *transfer)
{
ZEND_ASSERT(Z_TYPE(transfer->value) == IS_NULL && "Initial transfer value to fiber context must be NULL");
@@ -658,8 +673,7 @@ static ZEND_STACK_ALIGNED void zend_fiber_execute(zend_fiber_transfer *transfer)
zend_call_function(&fiber->fci, &fiber->fci_cache);
/* Cleanup callback and unset field to prevent GC / duplicate dtor issues. */
- zval_ptr_dtor(&fiber->fci.function_name);
- ZVAL_UNDEF(&fiber->fci.function_name);
+ zend_fiber_release_callable(fiber);
if (EG(exception)) {
if (!(fiber->flags & ZEND_FIBER_FLAG_DESTROYED)
@@ -1204,7 +1218,7 @@ static void zend_fiber_object_free(zend_object *object)
* finished. */
zend_fiber_release_coroutine(fiber, true);
- zval_ptr_dtor(&fiber->fci.function_name);
+ zend_fiber_release_callable(fiber);
zval_ptr_dtor(&fiber->result);
zval_ptr_dtor(&fiber->transfer);
@@ -1267,6 +1281,11 @@ static HashTable *zend_fiber_object_gc(zend_object *object, zval **table, int *n
if (fiber->coroutine != NULL) {
if (ZEND_COROUTINE_IS_FINISHED(fiber->coroutine)) {
zend_get_gc_buffer_add_zval(buf, &fiber->fci.function_name);
+
+ if (fiber->fci.object != NULL) {
+ zend_get_gc_buffer_add_obj(buf, fiber->fci.object);
+ }
+
zend_get_gc_buffer_add_zval(buf, &fiber->result);
zend_get_gc_buffer_add_zval(buf, &fiber->transfer);
@@ -1282,6 +1301,11 @@ static HashTable *zend_fiber_object_gc(zend_object *object, zval **table, int *n
if (fiber->context.status == ZEND_FIBER_STATUS_SUSPENDED) {
zend_get_gc_buffer_add_zval(buf, &fiber->fci.function_name);
+
+ if (fiber->fci.object != NULL) {
+ zend_get_gc_buffer_add_obj(buf, fiber->fci.object);
+ }
+
zend_get_gc_buffer_add_zval(buf, &fiber->transfer);
/* The coroutine object stays out of the buffer: the scheduler
@@ -1298,6 +1322,11 @@ static HashTable *zend_fiber_object_gc(zend_object *object, zval **table, int *n
}
zend_get_gc_buffer_add_zval(buf, &fiber->fci.function_name);
+
+ if (fiber->fci.object != NULL) {
+ zend_get_gc_buffer_add_obj(buf, fiber->fci.object);
+ }
+
zend_get_gc_buffer_add_zval(buf, &fiber->result);
zend_get_gc_buffer_add_zval(buf, &fiber->transfer);
@@ -1342,6 +1371,12 @@ ZEND_METHOD(Fiber, __construct)
// Keep a reference to closures or callable objects while the fiber is running.
Z_TRY_ADDREF(fiber->fci.function_name);
+
+ /* The object a class-string callable resolved to ($this of the calling method)
+ * is in no zval the fiber holds, and the call takes it from here. */
+ if (fiber->fci.object != NULL) {
+ GC_ADDREF(fiber->fci.object);
+ }
}
ZEND_METHOD(Fiber, start)
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index c9178aa9a877..a7209243ec2d 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -777,6 +777,10 @@ static HashTable *ts_coroutine_object_gc(zend_object *object, zval **table, int
if (ts->coro.fcall != NULL) {
zend_get_gc_buffer_add_zval(buf, &ts->coro.fcall->fci.function_name);
+ if (ts->coro.fcall->fci.object != NULL) {
+ zend_get_gc_buffer_add_obj(buf, ts->coro.fcall->fci.object);
+ }
+
for (uint32_t i = 0; i < ts->coro.fcall->fci.param_count; i++) {
zend_get_gc_buffer_add_zval(buf, &ts->coro.fcall->fci.params[i]);
}
diff --git a/ext/test_scheduler/tests/075_callable_object_outlives_caller.phpt b/ext/test_scheduler/tests/075_callable_object_outlives_caller.phpt
new file mode 100644
index 000000000000..c1847104a32b
--- /dev/null
+++ b/ext/test_scheduler/tests/075_callable_object_outlives_caller.phpt
@@ -0,0 +1,43 @@
+--TEST--
+test_scheduler: spawn() and a Fiber keep the object a class-string callable resolved to
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+label}\n";
+ }
+
+ public function spawnMethod() {
+ return TestScheduler\spawn([Target::class, 'method']);
+ }
+
+ public function makeFiber(): Fiber {
+ return new Fiber([Target::class, 'method']);
+ }
+
+ public function __destruct() {
+ echo "released {$this->label}\n";
+ }
+}
+
+// The only reference to each Target is the creating call's $this. The coroutine
+// and the Fiber hold their callable until the request frees them.
+$coroutine = (new Target("spawned"))->spawnMethod();
+$fiber = (new Target("fiber"))->makeFiber();
+$filler = array_fill(0, 100, new stdClass());
+TestScheduler\await($coroutine);
+$fiber->start();
+echo "done\n";
+?>
+--EXPECT--
+method: spawned
+method: fiber
+done
+released fiber
+released spawned
From c43060ea12df20afb37b1d735881792d973bf45d Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:47:14 +0000
Subject: [PATCH 20/33] shutdown: tell the scheduler of a bailout in a shutdown
function
php_call_shutdown_functions() catches the bailout of a fatal error or
exit() in a shutdown function and does not re-raise it, so the
scheduler never learned of it: coroutines queued before it ran after
it, and the destructors ran with a finished coroutine as the current
one. The catch now ends the scheduler as for a bailout in the script.
---
ext/standard/basic_functions.c | 8 ++++++
.../076_bailout_in_shutdown_function.phpt | 21 ++++++++++++++++
..._shutdown_function_after_script_fatal.phpt | 25 +++++++++++++++++++
.../tests/078_exit_in_shutdown_function.phpt | 20 +++++++++++++++
4 files changed, 74 insertions(+)
create mode 100644 ext/test_scheduler/tests/076_bailout_in_shutdown_function.phpt
create mode 100644 ext/test_scheduler/tests/077_bailout_in_shutdown_function_after_script_fatal.phpt
create mode 100644 ext/test_scheduler/tests/078_exit_in_shutdown_function.phpt
diff --git a/ext/standard/basic_functions.c b/ext/standard/basic_functions.c
index 1665feac37df..59e21b4eb948 100644
--- a/ext/standard/basic_functions.c
+++ b/ext/standard/basic_functions.c
@@ -33,6 +33,7 @@
#include "zend_exceptions.h"
#include "zend_ini.h"
#include "zend_operators.h"
+#include "zend_async_API.h"
#include "ext/standard/php_dns.h"
#include "ext/standard/php_uuencode.h"
#include "ext/standard/crc32_x86.h"
@@ -1651,6 +1652,13 @@ PHPAPI void php_call_shutdown_functions(void) /* {{{ */
if (BG(user_shutdown_function_names)) {
zend_try {
zend_hash_apply(BG(user_shutdown_function_names), user_shutdown_function_call);
+ } zend_catch {
+ /* The bailout stops here and is not re-raised. The scheduler learns of
+ * it as of a bailout in the script, so the coroutines queued before it
+ * do not run after it and the destructors run with a current coroutine. */
+ zend_try {
+ ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(true);
+ } zend_end_try();
} zend_end_try();
}
}
diff --git a/ext/test_scheduler/tests/076_bailout_in_shutdown_function.phpt b/ext/test_scheduler/tests/076_bailout_in_shutdown_function.phpt
new file mode 100644
index 000000000000..08486fa8329d
--- /dev/null
+++ b/ext/test_scheduler/tests/076_bailout_in_shutdown_function.phpt
@@ -0,0 +1,21 @@
+--TEST--
+test_scheduler: a fatal error in a shutdown function ends the coroutines it queued, as a fatal error in the script does
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECTF--
+main
+
+Fatal error: Cannot redeclare function redeclared() (previously declared in %s : eval()'d code:1) in %s : eval()'d code on line 1
diff --git a/ext/test_scheduler/tests/077_bailout_in_shutdown_function_after_script_fatal.phpt b/ext/test_scheduler/tests/077_bailout_in_shutdown_function_after_script_fatal.phpt
new file mode 100644
index 000000000000..407f7bcb1fe3
--- /dev/null
+++ b/ext/test_scheduler/tests/077_bailout_in_shutdown_function_after_script_fatal.phpt
@@ -0,0 +1,25 @@
+--TEST--
+test_scheduler: a fatal error in a shutdown function after a fatal error in the script also ends the coroutines it queued
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECTF--
+main
+
+Fatal error: Cannot redeclare function in_script() (previously declared in %s : eval()'d code:1) in %s : eval()'d code on line 1
+
+Fatal error: Cannot redeclare function in_shutdown() (previously declared in %s : eval()'d code:1) in %s : eval()'d code on line 1
diff --git a/ext/test_scheduler/tests/078_exit_in_shutdown_function.phpt b/ext/test_scheduler/tests/078_exit_in_shutdown_function.phpt
new file mode 100644
index 000000000000..9f2672e0ae33
--- /dev/null
+++ b/ext/test_scheduler/tests/078_exit_in_shutdown_function.phpt
@@ -0,0 +1,20 @@
+--TEST--
+test_scheduler: exit() in a shutdown function ends the coroutines it queued, as the bailout it is
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main
+shutdown function
From 28ef067c0d0b2078479be53e8961938f7b9b1d7e Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:22:25 +0000
Subject: [PATCH 21/33] async: drop the active coroutine count nothing writes
or reads
The globals lose a field; the Async API version 20261003, moved the
same day by the Fiber callable fix, covers it.
---
Zend/zend_async_API.h | 3 ---
1 file changed, 3 deletions(-)
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 298fc74f1dd1..668f0828a477 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -687,8 +687,6 @@ typedef struct {
zend_coroutine_t *coroutine;
/* The main coroutine (top-level script on the OS thread stack). */
zend_coroutine_t *main_coroutine;
- /* Number of live (not finished) coroutines. */
- unsigned int active_coroutine_count;
/* True while the scheduler's own machinery runs. */
bool in_scheduler_context;
/* Uncaught exception carried out of the shutdown drain. */
@@ -729,7 +727,6 @@ END_EXTERN_C()
#define ZEND_ASYNC_CURRENT_COROUTINE ZEND_ASYNC_G(coroutine)
#define ZEND_ASYNC_MAIN_COROUTINE ZEND_ASYNC_G(main_coroutine)
#define ZEND_ASYNC_EXIT_EXCEPTION ZEND_ASYNC_G(exit_exception)
-#define ZEND_ASYNC_ACTIVE_COROUTINE_COUNT ZEND_ASYNC_G(active_coroutine_count)
#define ZEND_ASYNC_IN_SCHEDULER_CONTEXT ZEND_ASYNC_G(in_scheduler_context)
#endif /* ZEND_ASYNC_API_H */
From 56938ff0570cda8fd3875413a40cae4d068e267b Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:22:34 +0000
Subject: [PATCH 22/33] async: start the shutdown destructors iterator on
shutdown_destructors() itself
---
Zend/zend_execute_API.c | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c
index d007226485f3..2675dec7c41f 100644
--- a/Zend/zend_execute_API.c
+++ b/Zend/zend_execute_API.c
@@ -240,11 +240,6 @@ static ZEND_COLD void zend_throw_or_error(uint32_t fetch_type, zend_class_entry
}
/* }}} */
-static void shutdown_destructors_iterator_entry(void)
-{
- shutdown_destructors();
-}
-
/* Torn down mid-pass: reset the cursor and give up on the remaining
* destructors — dispose may run inside a bailout, no user code from here. */
static void shutdown_destructors_coroutine_dtor(zend_coroutine_t *coroutine)
@@ -276,7 +271,7 @@ static bool shutdown_destructors_switch_handler(zend_coroutine_t *coroutine, boo
return false;
}
- iterator->internal_entry = shutdown_destructors_iterator_entry;
+ iterator->internal_entry = shutdown_destructors;
iterator->extended_dispose = shutdown_destructors_coroutine_dtor;
ZEND_ASYNC_ENQUEUE_COROUTINE(iterator);
From d76af5d1acb060c4511e4090adecc67be17a9f79 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:22:39 +0000
Subject: [PATCH 23/33] fibers: say that the fiber holds the coroutine, not the
other way round
---
Zend/zend_fibers.h | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/Zend/zend_fibers.h b/Zend/zend_fibers.h
index 4e00f3921d25..6367baf220f5 100644
--- a/Zend/zend_fibers.h
+++ b/Zend/zend_fibers.h
@@ -138,10 +138,12 @@ struct _zend_fiber {
* instead of switching contexts here. NULL leaves every one of them on
* the legacy path.
*
- * The coroutine owns a reference to the fiber; the fiber holds none back,
- * so nothing keeps a cycle alive. When the coroutine finishes, its result
- * moves into `result` and this pointer is cleared: from then on a
- * terminated coroutine-mode fiber is indistinguishable from a legacy one.
+ * The fiber holds a reference to the coroutine's object, shared with the
+ * scheduler; the coroutine points back through `extended_data` without
+ * one, so nothing keeps a cycle alive. When the coroutine finishes, its
+ * result moves into `result`; this pointer and its reference stay until
+ * the Fiber object is destroyed or freed, or its start fails
+ * (zend_fiber_release_coroutine()).
*/
zend_coroutine_t *coroutine;
From c440e4805a3def173c79adf8b3b3deab64ecf3bb Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:23:17 +0000
Subject: [PATCH 24/33] async: drop API surface no caller uses and the RFC does
not define
The call_on_main_stack and coroutine_from_object slots, the OBJ_REF object
model, ZEND_ASYNC_GET_EXCEPTION_CE (an alias of ZEND_ASYNC_GET_CE),
zend_async_is_enabled() and the empty globals destructor go.
---
Zend/zend_async_API.c | 48 +----------------------------
Zend/zend_async_API.h | 38 ++---------------------
Zend/zend_gc.c | 2 +-
ext/test_scheduler/test_scheduler.c | 10 ------
4 files changed, 5 insertions(+), 93 deletions(-)
diff --git a/Zend/zend_async_API.c b/Zend/zend_async_API.c
index 0a4815440ea2..1b484d6a52f5 100644
--- a/Zend/zend_async_API.c
+++ b/Zend/zend_async_API.c
@@ -30,11 +30,6 @@ static void internal_globals_ctor(zend_async_globals_t *globals)
memset(globals, 0, sizeof(zend_async_globals_t));
}
-static void internal_globals_dtor(zend_async_globals_t *globals)
-{
- (void) globals;
-}
-
#ifdef ZTS
static MUTEX_T scheduler_mutex = NULL;
#endif
@@ -45,8 +40,7 @@ void zend_async_globals_ctor(void)
scheduler_mutex = tsrm_mutex_alloc();
ts_allocate_fast_id(&zend_async_globals_id, &zend_async_globals_offset,
- sizeof(zend_async_globals_t), (ts_allocate_ctor) internal_globals_ctor,
- (ts_allocate_dtor) internal_globals_dtor);
+ sizeof(zend_async_globals_t), (ts_allocate_ctor) internal_globals_ctor, NULL);
ZEND_ASSERT(zend_async_globals_id != 0 && "zend_async_globals allocation failed");
#else
@@ -54,13 +48,6 @@ void zend_async_globals_ctor(void)
#endif
}
-void zend_async_globals_dtor(void)
-{
-#ifndef ZTS
- internal_globals_dtor(&zend_async_globals_api);
-#endif
-}
-
///////////////////////////////////////////////////////////////////
/// Internal context
///////////////////////////////////////////////////////////////////
@@ -479,11 +466,6 @@ static zend_class_entry *get_class_ce_default(zend_async_class type)
return NULL;
}
-static void default_call_on_main_stack(void (*fn)(void *), void *arg)
-{
- fn(arg);
-}
-
ZEND_API zend_async_new_coroutine_t zend_async_new_coroutine_fn = NULL;
ZEND_API zend_async_gc_new_coroutine_t zend_async_gc_new_coroutine_fn = NULL;
ZEND_API zend_async_enqueue_coroutine_t zend_async_enqueue_coroutine_fn = enqueue_coroutine_stub;
@@ -492,10 +474,7 @@ ZEND_API zend_async_cancel_t zend_async_cancel_fn = cancel_stub;
ZEND_API zend_async_scheduler_launch_t zend_async_scheduler_launch_fn = launch_stub;
ZEND_API zend_async_shutdown_t zend_async_shutdown_fn = shutdown_stub;
ZEND_API zend_async_get_class_ce_t zend_async_get_class_ce_fn = get_class_ce_default;
-ZEND_API zend_async_call_on_main_stack_t zend_async_call_on_main_stack_fn =
- default_call_on_main_stack;
ZEND_API zend_async_defer_t zend_async_defer_fn = defer_stub;
-ZEND_API zend_async_coroutine_from_object_t zend_async_coroutine_from_object_fn = NULL;
ZEND_API zend_async_intercept_fiber_t zend_async_intercept_fiber_fn = NULL;
ZEND_API zend_async_coroutine_execute_data_t zend_async_coroutine_execute_data_fn = NULL;
ZEND_API zend_async_coroutine_add_switch_handler_t zend_async_coroutine_add_switch_handler_fn = NULL;
@@ -585,18 +564,10 @@ ZEND_API bool zend_async_scheduler_register(
zend_async_get_class_ce_fn = api->get_class_ce;
}
- if (API_PROVIDES(api, call_on_main_stack)) {
- zend_async_call_on_main_stack_fn = api->call_on_main_stack;
- }
-
if (API_PROVIDES(api, defer)) {
zend_async_defer_fn = api->defer;
}
- if (API_PROVIDES(api, coroutine_from_object)) {
- zend_async_coroutine_from_object_fn = api->coroutine_from_object;
- }
-
if (API_PROVIDES(api, intercept_fiber)) {
zend_async_intercept_fiber_fn = api->intercept_fiber;
}
@@ -672,9 +643,7 @@ ZEND_API void zend_async_scheduler_unregister(void)
zend_async_scheduler_launch_fn = launch_stub;
zend_async_shutdown_fn = shutdown_stub;
zend_async_get_class_ce_fn = get_class_ce_default;
- zend_async_call_on_main_stack_fn = default_call_on_main_stack;
zend_async_defer_fn = defer_stub;
- zend_async_coroutine_from_object_fn = NULL;
zend_async_intercept_fiber_fn = NULL;
zend_async_coroutine_execute_data_fn = NULL;
zend_async_coroutine_add_switch_handler_fn = NULL;
@@ -693,7 +662,6 @@ ZEND_API void zend_async_scheduler_unregister(void)
void zend_async_api_shutdown(void)
{
- zend_async_globals_dtor();
zend_async_scheduler_unregister();
internal_context_keys_shutdown();
@@ -705,25 +673,11 @@ void zend_async_api_shutdown(void)
#endif
}
-ZEND_API bool zend_async_is_enabled(void)
-{
- return scheduler_module_name != NULL;
-}
-
ZEND_API const char *zend_async_get_scheduler_module(void)
{
return scheduler_module_name;
}
-ZEND_API zend_coroutine_t *zend_async_coroutine_from_object(zend_object *object)
-{
- if (zend_async_coroutine_from_object_fn == NULL) {
- return NULL;
- }
-
- return zend_async_coroutine_from_object_fn(object);
-}
-
///////////////////////////////////////////////////////////////////
/// Launch
///////////////////////////////////////////////////////////////////
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 668f0828a477..e8d21a2aaf30 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -145,10 +145,6 @@ struct _zend_coroutine_s {
#define ZEND_COROUTINE_F_CANCELLED (1u << 4) /* cancellation was requested */
#define ZEND_COROUTINE_F_MAIN (1u << 5) /* the main coroutine */
#define ZEND_COROUTINE_F_FIBER (1u << 6) /* runs a fiber; extended_data -> zend_fiber */
-/* object_offset points at a stored zend_object* instead of an embedded
- * object, for a provider whose coroutine and object live in separate
- * allocations. */
-#define ZEND_COROUTINE_F_OBJ_REF (1u << 7)
/* The body began executing. The status cannot say it: a coroutine waiting for
* its first run and one that yielded are both QUEUED. The scheduler sets it immediately before the body's
* first instruction, the main coroutine's included, and never for a coroutine
@@ -171,15 +167,12 @@ struct _zend_coroutine_s {
(((coroutine)->flags & ZEND_COROUTINE_F_STARTED) != 0)
#define ZEND_COROUTINE_SET_STARTED(coroutine) ((coroutine)->flags |= ZEND_COROUTINE_F_STARTED)
-/* The zend_object of a coroutine, or NULL for a plain C coroutine.
- * Embedded model: the object lives at object_offset within the same
- * allocation. OBJ_REF model: a zend_object* is stored at object_offset. */
+/* The zend_object of a coroutine, or NULL for a plain C coroutine. The object
+ * lives at object_offset within the same allocation. */
#define ZEND_COROUTINE_OBJECT(coroutine) \
((coroutine)->object_offset == 0 \
? NULL \
- : ((coroutine)->flags & ZEND_COROUTINE_F_OBJ_REF) \
- ? *(zend_object **) ((char *) (coroutine) + (coroutine)->object_offset) \
- : (zend_object *) ((char *) (coroutine) + (coroutine)->object_offset))
+ : (zend_object *) ((char *) (coroutine) + (coroutine)->object_offset))
/* Shared ownership of a coroutine goes through its zend_object. */
#define ZEND_COROUTINE_ADD_REF(coroutine) \
@@ -295,8 +288,6 @@ typedef zend_coroutine_t *(*zend_async_scheduler_launch_t)(void);
* scheduler to keep or clear; a cleared one ends the fiber without an error. */
typedef bool (*zend_async_shutdown_t)(void);
typedef zend_class_entry *(*zend_async_get_class_ce_t)(zend_async_class type);
-/* Run fn(arg) on the main coroutine's OS-thread stack (FFI/JNI etc.). */
-typedef void (*zend_async_call_on_main_stack_t)(void (*fn)(void *), void *arg);
/*
* Microtask: a one-shot task executed on the next scheduler tick.
@@ -353,16 +344,6 @@ typedef bool (*zend_async_defer_t)(zend_async_microtask_t *task);
* an exception, and the caller does not wait. */
typedef bool (*zend_async_coroutine_await_t)(zend_coroutine_t *coroutine);
-/*
- * Resolve the provider's coroutine object to its coroutine.
- *
- * The coroutine object belongs to the scheduler (its class, its allocation
- * model), so only the scheduler can walk that edge: the core keeps no
- * registry of coroutines. Returns NULL when the object is not one of the
- * scheduler's coroutines.
- */
-typedef zend_coroutine_t *(*zend_async_coroutine_from_object_t)(zend_object *object);
-
/*
* Where the engine offers a starting fiber to the scheduler.
*
@@ -442,9 +423,7 @@ typedef struct _zend_async_scheduler_api_s {
zend_async_scheduler_launch_t launch;
zend_async_shutdown_t shutdown;
zend_async_get_class_ce_t get_class_ce;
- zend_async_call_on_main_stack_t call_on_main_stack;
zend_async_defer_t defer;
- zend_async_coroutine_from_object_t coroutine_from_object;
zend_async_intercept_fiber_t intercept_fiber;
zend_async_coroutine_execute_data_t coroutine_execute_data;
zend_async_coroutine_add_switch_handler_t add_switch_handler;
@@ -467,9 +446,7 @@ ZEND_API extern zend_async_cancel_t zend_async_cancel_fn;
ZEND_API extern zend_async_scheduler_launch_t zend_async_scheduler_launch_fn;
ZEND_API extern zend_async_shutdown_t zend_async_shutdown_fn;
ZEND_API extern zend_async_get_class_ce_t zend_async_get_class_ce_fn;
-ZEND_API extern zend_async_call_on_main_stack_t zend_async_call_on_main_stack_fn;
ZEND_API extern zend_async_defer_t zend_async_defer_fn;
-ZEND_API extern zend_async_coroutine_from_object_t zend_async_coroutine_from_object_fn;
ZEND_API extern zend_async_intercept_fiber_t zend_async_intercept_fiber_fn;
ZEND_API extern zend_async_coroutine_execute_data_t zend_async_coroutine_execute_data_fn;
ZEND_API extern zend_async_coroutine_add_switch_handler_t zend_async_coroutine_add_switch_handler_fn;
@@ -481,11 +458,6 @@ ZEND_API extern zend_async_coroutine_add_awaiting_info_t zend_async_coroutine_ad
ZEND_API extern zend_async_coroutine_remove_awaiting_info_t zend_async_coroutine_remove_awaiting_info_fn;
ZEND_API extern zend_async_coroutine_get_awaiting_info_t zend_async_coroutine_get_awaiting_info_fn;
-/* Resolve a coroutine object to its coroutine through the provider's slot.
- * NULL when no scheduler is registered, when it provides no resolver, or
- * when the object is not one of its coroutines. */
-ZEND_API zend_coroutine_t *zend_async_coroutine_from_object(zend_object *object);
-
/* Launch the scheduler: calls the launch slot, marks the coroutine it returns
* as the main one and records it as current. False when no scheduler is
* registered or it failed to produce a main coroutine. */
@@ -496,7 +468,6 @@ ZEND_API bool zend_async_scheduler_register(
/* Withdraw the registration and reset every slot to its default. */
ZEND_API void zend_async_scheduler_unregister(void);
-ZEND_API bool zend_async_is_enabled(void);
/* The module name of the registered scheduler, or NULL when none. */
ZEND_API const char *zend_async_get_scheduler_module(void);
@@ -616,8 +587,6 @@ END_EXTERN_C()
#define ZEND_ASYNC_SCHEDULER_LAUNCH() zend_async_scheduler_launch()
#define ZEND_ASYNC_SHUTDOWN() zend_async_shutdown_fn()
#define ZEND_ASYNC_GET_CE(type) zend_async_get_class_ce_fn(type)
-#define ZEND_ASYNC_GET_EXCEPTION_CE(type) zend_async_get_class_ce_fn(type)
-#define ZEND_ASYNC_CALL_ON_MAIN_STACK(fn, arg) zend_async_call_on_main_stack_fn((fn), (arg))
#define ZEND_ASYNC_DEFER(task) zend_async_defer_fn(task)
/* The frame a parked coroutine waits in, or NULL. */
@@ -704,7 +673,6 @@ ZEND_API extern zend_async_globals_t zend_async_globals_api;
#endif
void zend_async_globals_ctor(void);
-void zend_async_globals_dtor(void);
void zend_async_api_shutdown(void);
END_EXTERN_C()
diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c
index 75ff52d2185d..d02bcbcb134c 100644
--- a/Zend/zend_gc.c
+++ b/Zend/zend_gc.c
@@ -2403,7 +2403,7 @@ ZEND_API int zend_gc_collect_cycles(void)
should_rerun_gc = false;
if (instanceof_function(EG(exception)->ce,
- ZEND_ASYNC_GET_EXCEPTION_CE(ZEND_ASYNC_EXCEPTION_CANCELLATION))) {
+ ZEND_ASYNC_GET_CE(ZEND_ASYNC_EXCEPTION_CANCELLATION))) {
zend_clear_exception();
}
}
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index a7209243ec2d..0419db61b3c4 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -802,15 +802,6 @@ static HashTable *ts_coroutine_object_gc(zend_object *object, zval **table, int
return NULL;
}
-static zend_coroutine_t *ts_coroutine_from_object(zend_object *object)
-{
- if (object->ce != ts_ce_coroutine) {
- return NULL;
- }
-
- return &ts_from_obj(object)->coro;
-}
-
static ts_coroutine_t *ts_coroutine_new(void)
{
zend_object *object = ts_coroutine_object_create(ts_ce_coroutine);
@@ -1853,7 +1844,6 @@ static zend_async_scheduler_api_t ts_scheduler_api = {
.shutdown = ts_shutdown,
.cancel = ts_cancel,
.get_class_ce = ts_get_class_ce,
- .coroutine_from_object = ts_coroutine_from_object,
.intercept_fiber = ts_intercept_fiber,
.coroutine_execute_data = ts_coroutine_execute_data,
.defer = ts_defer,
From 5e901acf6c05917772ddd897a29509a4240b3758 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:24:00 +0000
Subject: [PATCH 25/33] async: document that a finish handler fires at most
once and a throwing one may end the notify
---
Zend/zend_async_API.h | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index e8d21a2aaf30..b8d911b995af 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -80,8 +80,11 @@ typedef zend_string *(*zend_coroutine_awaiting_info_fn)(zend_coroutine_t *corout
*/
typedef bool (*zend_coroutine_switch_handler_fn)(zend_coroutine_t *coroutine, bool is_enter);
-/* Fires exactly once, however the coroutine ends — return, exception,
- * cancellation, bailout unwind. Clearing ->exception here marks it handled.
+/* Fires at most once, however the coroutine ends — return, exception,
+ * cancellation, bailout unwind. A handler that throws may end the notify: the
+ * provider is free to drop the handlers after it, uncalled, so a handler that
+ * must fire belongs on a coroutine whose handlers its owner controls (as the
+ * GC's do). Clearing ->exception here marks it handled.
* waiter/data are stored at registration and handed back verbatim: waiter is
* the coroutine parked on this one (NULL when it hides behind data), data is
* the consumer's context. is_bailout=true: the scheduler is dying — clean own
From 5e53ee222505bfb46b4a4952b86381202f4ccb5c Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:24:14 +0000
Subject: [PATCH 26/33] async: drop new_coroutine's extra_size, which nothing
passed or honored
---
Zend/zend_async_API.h | 12 +++++-------
ext/test_scheduler/test_scheduler.c | 4 +---
2 files changed, 6 insertions(+), 10 deletions(-)
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index b8d911b995af..8434ded93900 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -251,13 +251,12 @@ struct _zend_coroutine_s {
/// Scheduler API slots
///////////////////////////////////////////////////////////////////
-/* Allocate a coroutine in STATUS_CREATED; extra_size bytes are appended
- * for the caller. */
-typedef zend_coroutine_t *(*zend_async_new_coroutine_t)(size_t extra_size);
+/* Allocate a coroutine in STATUS_CREATED. */
+typedef zend_coroutine_t *(*zend_async_new_coroutine_t)(void);
/* Allocate a coroutine for the engine's own GC bookkeeping (running
* zend_gc_collect_cycles() and its destructor phase). A separate slot lets a
* scheduler treat these specially — priority, concurrency limits — if it cares.
- * NULL falls back to new_coroutine(0); see ZEND_ASYNC_GC_NEW_COROUTINE(). */
+ * NULL falls back to new_coroutine(); see ZEND_ASYNC_GC_NEW_COROUTINE(). */
typedef zend_coroutine_t *(*zend_async_gc_new_coroutine_t)(void);
/* Put a CREATED/SUSPENDED coroutine into the run queue (-> STATUS_QUEUED).
* Enqueuing a fresh coroutine and resuming a suspended one are the same
@@ -559,15 +558,14 @@ END_EXTERN_C()
#define ZEND_ASYNC_INTERNAL_CONTEXT_DESTROY(coroutine) \
zend_async_internal_context_destroy(coroutine)
-#define ZEND_ASYNC_NEW_COROUTINE() zend_async_new_coroutine_fn(0)
-#define ZEND_ASYNC_NEW_COROUTINE_EX(extra_size) zend_async_new_coroutine_fn(extra_size)
+#define ZEND_ASYNC_NEW_COROUTINE() zend_async_new_coroutine_fn()
/* A coroutine for the engine's own GC bookkeeping. Falls back to the plain
* new_coroutine slot when the scheduler does not distinguish them; NULL when
* the provider cannot mint C coroutines at all. */
#define ZEND_ASYNC_GC_NEW_COROUTINE() \
(zend_async_gc_new_coroutine_fn != NULL \
? zend_async_gc_new_coroutine_fn() \
- : zend_async_new_coroutine_fn != NULL ? zend_async_new_coroutine_fn(0) : NULL)
+ : zend_async_new_coroutine_fn != NULL ? zend_async_new_coroutine_fn() : NULL)
#define ZEND_ASYNC_ENQUEUE_COROUTINE(coroutine) \
zend_async_enqueue_coroutine_fn((coroutine), NULL, false)
/* Enqueue-with-error: the resume/cancellation delivery channel. */
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index 0419db61b3c4..2987612ce9b5 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -1365,10 +1365,8 @@ static zend_coroutine_t *ts_launch(void)
/// The Async Core slots
///////////////////////////////////////////////////////////////////
-static zend_coroutine_t *ts_new_coroutine(size_t extra_size)
+static zend_coroutine_t *ts_new_coroutine(void)
{
- (void) extra_size;
-
return &ts_coroutine_new()->coro;
}
From 5d339585c344536dec35b2240495ea1d16dec4ed Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:25:59 +0000
Subject: [PATCH 27/33] test_scheduler: say why seven ported tests expect other
output than their upstream originals
---
ext/test_scheduler/tests/034_gc_inside_fiber.phpt | 10 ++++++++++
.../tests/036_shutdown_generator_in_fiber.phpt | 11 +++++++++++
.../tests/055_generator_frames_scan_1.phpt | 10 ++++++++++
.../tests/056_generator_frames_scan_2.phpt | 10 ++++++++++
.../tests/057_generator_frames_scan_3.phpt | 10 ++++++++++
.../tests/058_generator_frames_scan_4.phpt | 10 ++++++++++
.../tests/059_generator_shutdown_dtor_order.phpt | 11 +++++++++++
7 files changed, 72 insertions(+)
diff --git a/ext/test_scheduler/tests/034_gc_inside_fiber.phpt b/ext/test_scheduler/tests/034_gc_inside_fiber.phpt
index b55d48b7810e..3fffe779d542 100644
--- a/ext/test_scheduler/tests/034_gc_inside_fiber.phpt
+++ b/ext/test_scheduler/tests/034_gc_inside_fiber.phpt
@@ -1,5 +1,15 @@
--TEST--
Bug GH-10496 001 (Segfault when garbage collector is invoked inside of fiber) — under test_scheduler
+--DESCRIPTION--
+The expected output departs from the upstream
+Zend/tests/fibers/gh10496-001.phpt. Under a scheduler a fiber parked at
+Fiber::suspend() is not collected by gc_collect_cycles() when the cycle runs
+through its body's closure: the closure sits in the coroutine's fcall, and a
+parked fiber keeps its coroutine out of the collector's view, as the scheduler
+holds references to it the collector cannot count. The fiber goes when the
+scheduler ends it at shutdown, so "Cleaned" and the destructor's output follow
+"Collected". The scheduler RFC lists this behavior change as "A fiber parked
+in Fiber::suspend() is ended by the scheduler, not collected".
--EXTENSIONS--
test_scheduler
--INI--
diff --git a/ext/test_scheduler/tests/036_shutdown_generator_in_fiber.phpt b/ext/test_scheduler/tests/036_shutdown_generator_in_fiber.phpt
index bcd2c0e71363..4f38a06493f7 100644
--- a/ext/test_scheduler/tests/036_shutdown_generator_in_fiber.phpt
+++ b/ext/test_scheduler/tests/036_shutdown_generator_in_fiber.phpt
@@ -1,5 +1,16 @@
--TEST--
Bug GH-9916 009 (Entering shutdown sequence with a fiber suspended in a Generator emits an unavoidable fatal error or crashes) — under test_scheduler
+--DESCRIPTION--
+The expected output departs from the upstream
+Zend/tests/fibers/gh9916-009.phpt. Under a scheduler the fiber still parked
+when the script ends is ended by the scheduler with a graceful exit thrown at
+its Fiber::suspend(), not destroyed with the objects. The generator is unwound
+by that exit instead of being force-closed, so the yield from in its finally
+block is allowed: it yields its first element back to $gen->current() in the
+fiber's body, which goes on to print the line marked "Not executed". Upstream
+throws "Cannot use yield from in a force-closed generator" there. The
+scheduler RFC lists this behavior change as "A fiber parked in
+Fiber::suspend() is ended by the scheduler, not collected".
--EXTENSIONS--
test_scheduler
--INI--
diff --git a/ext/test_scheduler/tests/055_generator_frames_scan_1.phpt b/ext/test_scheduler/tests/055_generator_frames_scan_1.phpt
index 7fade94361d9..0b6e8149f020 100644
--- a/ext/test_scheduler/tests/055_generator_frames_scan_1.phpt
+++ b/ext/test_scheduler/tests/055_generator_frames_scan_1.phpt
@@ -1,5 +1,15 @@
--TEST--
GH-15330 003: Do not scan generator frames more than once — under test_scheduler
+--DESCRIPTION--
+The expected output departs from the upstream
+Zend/tests/generators/gh15330-003.phpt. Under a scheduler a fiber parked at
+Fiber::suspend() is not collected by gc_collect_cycles() when the cycle runs
+through its body's closure: the closure sits in the coroutine's fcall, and a
+parked fiber keeps its coroutine out of the collector's view, as the scheduler
+holds references to it the collector cannot count. The fiber goes when the
+scheduler ends it at shutdown, so the destructor's output follows "==DONE==".
+The scheduler RFC lists this behavior change as "A fiber parked in
+Fiber::suspend() is ended by the scheduler, not collected".
--EXTENSIONS--
test_scheduler
--INI--
diff --git a/ext/test_scheduler/tests/056_generator_frames_scan_2.phpt b/ext/test_scheduler/tests/056_generator_frames_scan_2.phpt
index 137749e119b9..7dfd58fe8cfa 100644
--- a/ext/test_scheduler/tests/056_generator_frames_scan_2.phpt
+++ b/ext/test_scheduler/tests/056_generator_frames_scan_2.phpt
@@ -1,5 +1,15 @@
--TEST--
GH-15330 004: Do not scan generator frames more than once — under test_scheduler
+--DESCRIPTION--
+The expected output departs from the upstream
+Zend/tests/generators/gh15330-004.phpt. Under a scheduler a fiber parked at
+Fiber::suspend() is not collected by gc_collect_cycles() when the cycle runs
+through its body's closure: the closure sits in the coroutine's fcall, and a
+parked fiber keeps its coroutine out of the collector's view, as the scheduler
+holds references to it the collector cannot count. The fiber goes when the
+scheduler ends it at shutdown, so the destructor's output follows "==DONE==".
+The scheduler RFC lists this behavior change as "A fiber parked in
+Fiber::suspend() is ended by the scheduler, not collected".
--EXTENSIONS--
test_scheduler
--INI--
diff --git a/ext/test_scheduler/tests/057_generator_frames_scan_3.phpt b/ext/test_scheduler/tests/057_generator_frames_scan_3.phpt
index b4fc611f63dc..75a3e1bc54a4 100644
--- a/ext/test_scheduler/tests/057_generator_frames_scan_3.phpt
+++ b/ext/test_scheduler/tests/057_generator_frames_scan_3.phpt
@@ -1,5 +1,15 @@
--TEST--
GH-15330 005: Do not scan generator frames more than once — under test_scheduler
+--DESCRIPTION--
+The expected output departs from the upstream
+Zend/tests/generators/gh15330-005.phpt. Under a scheduler a fiber parked at
+Fiber::suspend() is not collected by gc_collect_cycles() when the cycle runs
+through its body's closure: the closure sits in the coroutine's fcall, and a
+parked fiber keeps its coroutine out of the collector's view, as the scheduler
+holds references to it the collector cannot count. The fiber goes when the
+scheduler ends it at shutdown, so the destructor's output follows "==DONE==".
+The scheduler RFC lists this behavior change as "A fiber parked in
+Fiber::suspend() is ended by the scheduler, not collected".
--EXTENSIONS--
test_scheduler
--INI--
diff --git a/ext/test_scheduler/tests/058_generator_frames_scan_4.phpt b/ext/test_scheduler/tests/058_generator_frames_scan_4.phpt
index 5d9912a9c0eb..388556fae2a8 100644
--- a/ext/test_scheduler/tests/058_generator_frames_scan_4.phpt
+++ b/ext/test_scheduler/tests/058_generator_frames_scan_4.phpt
@@ -1,5 +1,15 @@
--TEST--
GH-15330 006: Do not scan generator frames more than once — under test_scheduler
+--DESCRIPTION--
+The expected output departs from the upstream
+Zend/tests/generators/gh15330-006.phpt. Under a scheduler a fiber parked at
+Fiber::suspend() is not collected by gc_collect_cycles() when the cycle runs
+through its body's closure: the closure sits in the coroutine's fcall, and a
+parked fiber keeps its coroutine out of the collector's view, as the scheduler
+holds references to it the collector cannot count. The fiber goes when the
+scheduler ends it at shutdown, so the destructor's output follows "==DONE==".
+The scheduler RFC lists this behavior change as "A fiber parked in
+Fiber::suspend() is ended by the scheduler, not collected".
--EXTENSIONS--
test_scheduler
--INI--
diff --git a/ext/test_scheduler/tests/059_generator_shutdown_dtor_order.phpt b/ext/test_scheduler/tests/059_generator_shutdown_dtor_order.phpt
index 919bb6999bf9..3ae66d63d82b 100644
--- a/ext/test_scheduler/tests/059_generator_shutdown_dtor_order.phpt
+++ b/ext/test_scheduler/tests/059_generator_shutdown_dtor_order.phpt
@@ -1,5 +1,16 @@
--TEST--
GH-15866: Core dumped in Zend/zend_generators.c — under test_scheduler
+--DESCRIPTION--
+The expected output departs from the upstream
+Zend/tests/generators/gh15866.phpt. Under a scheduler a fiber parked at
+Fiber::suspend() is not collected by gc_collect_cycles() when the cycle runs
+through its body's closure: the closure sits in the coroutine's fcall, and a
+parked fiber keeps its coroutine out of the collector's view, as the scheduler
+holds references to it the collector cannot count. The fiber goes when the
+scheduler ends it at shutdown, so the generator's and the fiber's finally
+blocks and the destructor follow "==DONE==". The scheduler RFC lists this
+behavior change as "A fiber parked in Fiber::suspend() is ended by the
+scheduler, not collected".
--EXTENSIONS--
test_scheduler
--INI--
From 3d155c10565897a853eac6fc8bd6fb69ec2a19d9 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:26:54 +0000
Subject: [PATCH 28/33] test_scheduler: add a fault seam for new_coroutine and
the first enqueue
---
ext/test_scheduler/test_scheduler.c | 34 +++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index 2987612ce9b5..905ef5f76a4b 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -152,6 +152,8 @@ ZEND_BEGIN_MODULE_GLOBALS(test_scheduler)
HashTable coroutines;
/* The loop. It is not a task, so it lives here and not in the table. */
ts_coroutine_t *scheduler;
+ zend_long fail_new_coroutine;
+ zend_long fail_enqueue;
ZEND_END_MODULE_GLOBALS(test_scheduler)
ZEND_DECLARE_MODULE_GLOBALS(test_scheduler)
@@ -165,6 +167,14 @@ PHP_INI_BEGIN()
PHP_INI_ENTRY("test_scheduler.enable", "0", PHP_INI_SYSTEM, NULL)
/* Tests only: register as if built for this Async API version; 0 is the real one. */
PHP_INI_ENTRY("test_scheduler.api_version", "0", PHP_INI_SYSTEM, NULL)
+ /* Tests only: the n-th new_coroutine or gc_new_coroutine call from now
+ * returns NULL, as a provider that cannot create a coroutine does; 0 is off. */
+ STD_PHP_INI_ENTRY("test_scheduler.fail_new_coroutine", "0", PHP_INI_ALL, OnUpdateLong,
+ fail_new_coroutine, zend_test_scheduler_globals, test_scheduler_globals)
+ /* Tests only: the n-th enqueue of a coroutine never queued before fails
+ * with an Error, as a provider that cannot give it a stack does; 0 is off. */
+ STD_PHP_INI_ENTRY("test_scheduler.fail_enqueue", "0", PHP_INI_ALL, OnUpdateLong,
+ fail_enqueue, zend_test_scheduler_globals, test_scheduler_globals)
PHP_INI_END()
/* False when disabled: MINIT registered nothing. */
@@ -192,6 +202,12 @@ static zend_always_inline ts_coroutine_t *ts_from_coro(zend_coroutine_t *coro)
static bool ts_enqueue(zend_coroutine_t *coroutine, zend_object *error, bool transfer_error);
+/* True for the call an armed fault countdown picks; counts the countdown down. */
+static zend_always_inline bool ts_fault_hit(zend_long *countdown)
+{
+ return *countdown > 0 && --(*countdown) == 0;
+}
+
///////////////////////////////////////////////////////////////////
/// Switch/finish handler vectors
///////////////////////////////////////////////////////////////////
@@ -1367,6 +1383,10 @@ static zend_coroutine_t *ts_launch(void)
static zend_coroutine_t *ts_new_coroutine(void)
{
+ if (UNEXPECTED(ts_fault_hit(&TSG(fail_new_coroutine)))) {
+ return NULL;
+ }
+
return &ts_coroutine_new()->coro;
}
@@ -1375,6 +1395,10 @@ static zend_coroutine_t *ts_new_coroutine(void)
* does would tell them apart here. */
static zend_coroutine_t *ts_gc_new_coroutine(void)
{
+ if (UNEXPECTED(ts_fault_hit(&TSG(fail_new_coroutine)))) {
+ return NULL;
+ }
+
return &ts_coroutine_new()->coro;
}
@@ -1391,6 +1415,16 @@ static bool ts_enqueue(zend_coroutine_t *coroutine, zend_object *error, bool tra
return false;
}
+ if (UNEXPECTED(ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_CREATED
+ && ts_fault_hit(&TSG(fail_enqueue)))) {
+ if (error != NULL && transfer_error) {
+ OBJ_RELEASE(error);
+ }
+
+ zend_throw_error(NULL, "Cannot enqueue the coroutine: test_scheduler.fail_enqueue");
+ return false;
+ }
+
/* Thrown at the suspension point when the coroutine runs. */
if (error != NULL) {
if (ts->pending_error != NULL) {
From a88d007792fdc60c4254d3724ff936e409070bb1 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:28:44 +0000
Subject: [PATCH 29/33] test_scheduler: start the loop in suspend when nothing
started it
---
ext/test_scheduler/test_scheduler.c | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
index 905ef5f76a4b..d4ab079eeb0d 100644
--- a/ext/test_scheduler/test_scheduler.c
+++ b/ext/test_scheduler/test_scheduler.c
@@ -1601,6 +1601,12 @@ static bool ts_suspend(bool from_main, bool is_bailout)
return false;
}
+ /* Nothing may have started the loop yet: after the main flow ended it is
+ * gone, and a destructor can suspend with nothing queued. */
+ if (UNEXPECTED(!ts_scheduler_ensure())) {
+ return false;
+ }
+
ZEND_COROUTINE_SET_STATUS(&self->coro, ZEND_COROUTINE_STATUS_SUSPENDED);
self->execute_data = EG(current_execute_data);
ZEND_ASYNC_CURRENT_COROUTINE = &TSG(scheduler)->coro;
From 5e5fffa7a1a25d03f3dec7be61b3e6669e7d711b Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:46:56 +0000
Subject: [PATCH 30/33] async: clear the error of a shutdown iterator the
scheduler refuses
The error stayed in EG(exception) across the switch and reached the coroutine entered: a destructor's await saw it and the coroutine it awaited finished unrun. The driving coroutine now finishes the pass itself when it resumes, as when no iterator could be created.
---
Zend/zend_async_API.h | 3 ++-
Zend/zend_execute_API.c | 7 ++++++-
Zend/zend_objects_API.c | 8 +++++++-
3 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index 8434ded93900..e06cb95a059f 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -72,7 +72,8 @@ typedef zend_string *(*zend_coroutine_awaiting_info_fn)(zend_coroutine_t *corout
* (is_enter=false) or back in (is_enter=true). Synchronous on purpose: a
* microtask runs only on the next tick, which this late in shutdown may never
* come, so the handler has to react at the switch itself. Return false to drop
- * the handler after this call, true to keep it armed.
+ * the handler after this call, true to keep it armed. A handler leaves no
+ * exception: the switch it runs in carries none to the coroutine entered.
*
* Finishing is a separate mechanism (zend_coroutine_finish_handler_fn): one
* coroutine may have many awaiters, which doesn't fit a switch handler. Both
diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c
index 2675dec7c41f..c34a0a99bdee 100644
--- a/Zend/zend_execute_API.c
+++ b/Zend/zend_execute_API.c
@@ -274,7 +274,12 @@ static bool shutdown_destructors_switch_handler(zend_coroutine_t *coroutine, boo
iterator->internal_entry = shutdown_destructors;
iterator->extended_dispose = shutdown_destructors_coroutine_dtor;
- ZEND_ASYNC_ENQUEUE_COROUTINE(iterator);
+ /* Refused: the driving coroutine finishes the pass itself when it resumes,
+ * as when no iterator could be created. A switch handler leaves no
+ * exception behind. */
+ if (UNEXPECTED(!ZEND_ASYNC_ENQUEUE_COROUTINE(iterator))) {
+ zend_clear_exception();
+ }
return false;
}
diff --git a/Zend/zend_objects_API.c b/Zend/zend_objects_API.c
index f763f73e4d34..91796407eb7f 100644
--- a/Zend/zend_objects_API.c
+++ b/Zend/zend_objects_API.c
@@ -22,6 +22,7 @@
#include "zend_variables.h"
#include "zend_API.h"
#include "zend_objects_API.h"
+#include "zend_exceptions.h"
#include "zend_fibers.h"
#include "zend_async_API.h"
#include "zend_execute.h"
@@ -108,7 +109,12 @@ static bool zend_objects_store_call_destructors_async_switch_handler(zend_corout
iterator->internal_entry = zend_objects_store_call_destructors_async_iterator_entry;
iterator->extended_dispose = zend_objects_store_call_destructors_async_coroutine_dtor;
- ZEND_ASYNC_ENQUEUE_COROUTINE(iterator);
+ /* Refused: the driving coroutine finishes the pass itself when it resumes,
+ * as when no iterator could be created. A switch handler leaves no
+ * exception behind. */
+ if (UNEXPECTED(!ZEND_ASYNC_ENQUEUE_COROUTINE(iterator))) {
+ zend_clear_exception();
+ }
return false;
}
From a67d22ddd9beec23e237bf8de3defb5521329a6a Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 10:06:36 +0000
Subject: [PATCH 31/33] gc: handle a destructor iterator the scheduler refuses
like one it could not create
The enqueue error stayed in the GC coroutine, which then ended with an
exception no caller handles (a provider may end the request on it), and
the run gave up its rerun. Clear it, as a missing iterator leaves none:
the run is redone with the destructors called.
---
Zend/zend_gc.c | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c
index d02bcbcb134c..ae5573624649 100644
--- a/Zend/zend_gc.c
+++ b/Zend/zend_gc.c
@@ -2152,11 +2152,16 @@ static zend_coroutine_t *gc_spawn_destructors_coroutine(void)
coroutine, gc_destructors_finish_handler, GC_G(gc_coroutine), NULL);
/* Without the handler nobody ever counts this iterator out: treat a failed
- * registration like a failed enqueue. */
+ * registration like a failed enqueue. Either is a refused iterator, handled
+ * as one that could not be created: the error is cleared, as it belongs to
+ * no caller (left in the GC coroutine, it would end that coroutine with an
+ * exception a provider may end the request on), and the run is redone or
+ * the destructors wait for the next collection. */
if (UNEXPECTED(handler_id == 0 || !ZEND_ASYNC_ENQUEUE_COROUTINE(coroutine))) {
ZEND_ASYNC_REMOVE_FINISH_HANDLER(coroutine, handler_id);
GC_G(dtor_coroutine) = NULL;
GC_G(dtor_pending)--;
+ zend_clear_exception();
return NULL;
}
From cc9907bf8d13603b00be3b71a391579d94c810c9 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:47:04 +0000
Subject: [PATCH 32/33] async: say who owns the coroutine new_coroutine returns
---
Zend/zend_async_API.h | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h
index e06cb95a059f..308922006b8c 100644
--- a/Zend/zend_async_API.h
+++ b/Zend/zend_async_API.h
@@ -252,12 +252,18 @@ struct _zend_coroutine_s {
/// Scheduler API slots
///////////////////////////////////////////////////////////////////
-/* Allocate a coroutine in STATUS_CREATED. */
+/* Allocate a coroutine in STATUS_CREATED, or NULL when the scheduler cannot.
+ * The scheduler owns the coroutine from its creation: the caller borrows it
+ * until it finishes and takes ZEND_COROUTINE_ADD_REF() to keep it longer. A
+ * coroutine the caller never manages to enqueue stays the scheduler's until the
+ * request ends, and it waits for nothing. */
typedef zend_coroutine_t *(*zend_async_new_coroutine_t)(void);
/* Allocate a coroutine for the engine's own GC bookkeeping (running
* zend_gc_collect_cycles() and its destructor phase). A separate slot lets a
* scheduler treat these specially — priority, concurrency limits — if it cares.
- * NULL falls back to new_coroutine(); see ZEND_ASYNC_GC_NEW_COROUTINE(). */
+ * NULL falls back to new_coroutine(); see ZEND_ASYNC_GC_NEW_COROUTINE(). A NULL
+ * from this slot is a failure the engine survives, not a way to throttle: a
+ * scheduler that keeps refusing never runs the destructors of garbage cycles. */
typedef zend_coroutine_t *(*zend_async_gc_new_coroutine_t)(void);
/* Put a CREATED/SUSPENDED coroutine into the run queue (-> STATUS_QUEUED).
* Enqueuing a fresh coroutine and resuming a suspended one are the same
From d7f3e1dbcbdb78cb630ad3fb0b206df946025ae7 Mon Sep 17 00:00:00 2001
From: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date: Sat, 3 Oct 2026 08:30:15 +0000
Subject: [PATCH 33/33] test_scheduler: test the core's paths where a coroutine
cannot be created or queued
---
.../tests/079_fault_fiber_start_enqueue.phpt | 31 ++++++++++
.../tests/080_fault_gc_coroutine.phpt | 56 +++++++++++++++++++
.../081_fault_shutdown_iterator_new.phpt | 31 ++++++++++
.../082_fault_shutdown_iterator_enqueue.phpt | 31 ++++++++++
...fault_shutdown_suspend_unstarted_loop.phpt | 36 ++++++++++++
.../084_fault_objects_store_iterator_new.phpt | 36 ++++++++++++
..._fault_objects_store_iterator_enqueue.phpt | 41 ++++++++++++++
7 files changed, 262 insertions(+)
create mode 100644 ext/test_scheduler/tests/079_fault_fiber_start_enqueue.phpt
create mode 100644 ext/test_scheduler/tests/080_fault_gc_coroutine.phpt
create mode 100644 ext/test_scheduler/tests/081_fault_shutdown_iterator_new.phpt
create mode 100644 ext/test_scheduler/tests/082_fault_shutdown_iterator_enqueue.phpt
create mode 100644 ext/test_scheduler/tests/083_fault_shutdown_suspend_unstarted_loop.phpt
create mode 100644 ext/test_scheduler/tests/084_fault_objects_store_iterator_new.phpt
create mode 100644 ext/test_scheduler/tests/085_fault_objects_store_iterator_enqueue.phpt
diff --git a/ext/test_scheduler/tests/079_fault_fiber_start_enqueue.phpt b/ext/test_scheduler/tests/079_fault_fiber_start_enqueue.phpt
new file mode 100644
index 000000000000..7d411aa791bb
--- /dev/null
+++ b/ext/test_scheduler/tests/079_fault_fiber_start_enqueue.phpt
@@ -0,0 +1,31 @@
+--TEST--
+test_scheduler: a Fiber whose coroutine cannot be queued fails to start and may start again
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+} catch (Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+var_dump($fiber->isStarted(), $fiber->isTerminated());
+
+$fiber->start();
+var_dump($fiber->isTerminated());
+?>
+--EXPECT--
+Cannot enqueue the coroutine: test_scheduler.fail_enqueue
+bool(false)
+bool(false)
+body
+bool(true)
diff --git a/ext/test_scheduler/tests/080_fault_gc_coroutine.phpt b/ext/test_scheduler/tests/080_fault_gc_coroutine.phpt
new file mode 100644
index 000000000000..865687e6e2e6
--- /dev/null
+++ b/ext/test_scheduler/tests/080_fault_gc_coroutine.phpt
@@ -0,0 +1,56 @@
+--TEST--
+test_scheduler: gc_collect_cycles() when its coroutine or its destructor iterator cannot be created or queued
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $node;
+}
+
+/* The GC coroutine is the first coroutine the call creates, the destructor
+ * iterator the second. */
+$faults = [['fail_new_coroutine', 1], ['fail_enqueue', 1], ['fail_new_coroutine', 2], ['fail_enqueue', 2]];
+foreach ($faults as [$setting, $nth]) {
+ echo "$setting=$nth\n";
+ make_cycle();
+ ini_set("test_scheduler.$setting", $nth);
+
+ try {
+ var_dump(gc_collect_cycles());
+ } catch (Error $e) {
+ echo $e->getMessage(), "\n";
+ }
+
+ ini_set("test_scheduler.$setting", 0);
+ var_dump(gc_collect_cycles());
+}
+?>
+--EXPECT--
+fail_new_coroutine=1
+int(0)
+destructor
+int(1)
+fail_enqueue=1
+Cannot enqueue the coroutine: test_scheduler.fail_enqueue
+destructor
+int(1)
+fail_new_coroutine=2
+destructor
+int(0)
+int(1)
+fail_enqueue=2
+destructor
+int(0)
+int(1)
diff --git a/ext/test_scheduler/tests/081_fault_shutdown_iterator_new.phpt b/ext/test_scheduler/tests/081_fault_shutdown_iterator_new.phpt
new file mode 100644
index 000000000000..4398ab8d68af
--- /dev/null
+++ b/ext/test_scheduler/tests/081_fault_shutdown_iterator_new.phpt
@@ -0,0 +1,31 @@
+--TEST--
+test_scheduler: the shutdown destructors go on in the driving coroutine when no iterator can be created
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+name} start\n";
+ TestScheduler\await(TestScheduler\spawn(fn() => null));
+ echo "{$this->name} end\n";
+ }
+}
+
+$a = new Waiter('a');
+$b = new Waiter('b');
+
+/* Each destructor's await would start an iterator to carry the pass on. */
+ini_set('test_scheduler.fail_new_coroutine', 1);
+echo "end of script\n";
+?>
+--EXPECT--
+end of script
+b start
+b end
+a start
+a end
diff --git a/ext/test_scheduler/tests/082_fault_shutdown_iterator_enqueue.phpt b/ext/test_scheduler/tests/082_fault_shutdown_iterator_enqueue.phpt
new file mode 100644
index 000000000000..82f00f7d7704
--- /dev/null
+++ b/ext/test_scheduler/tests/082_fault_shutdown_iterator_enqueue.phpt
@@ -0,0 +1,31 @@
+--TEST--
+test_scheduler: the shutdown destructors go on in the driving coroutine when their iterator cannot be queued
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+name} start\n";
+ TestScheduler\await(TestScheduler\spawn(function () {
+ echo "{$this->name} spawned\n";
+ }));
+ echo "{$this->name} end\n";
+ }
+}
+
+$a = new Waiter('a');
+
+/* The spawn is the first queued coroutine, the iterator the second. */
+ini_set('test_scheduler.fail_enqueue', 2);
+echo "end of script\n";
+?>
+--EXPECT--
+end of script
+a start
+a spawned
+a end
diff --git a/ext/test_scheduler/tests/083_fault_shutdown_suspend_unstarted_loop.phpt b/ext/test_scheduler/tests/083_fault_shutdown_suspend_unstarted_loop.phpt
new file mode 100644
index 000000000000..5efbaec71115
--- /dev/null
+++ b/ext/test_scheduler/tests/083_fault_shutdown_suspend_unstarted_loop.phpt
@@ -0,0 +1,36 @@
+--TEST--
+test_scheduler: a shutdown destructor that parks with no iterator and no loop running ends in a deadlock, not a crash
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECTF--
+end of script
+parking
+
+Fatal error: Uncaught TestScheduler\CancellationError: Deadlock detected in %s:%d
+Stack trace:
+#0 {main}
+ thrown in %s on line %d
+
+Fatal error: Uncaught TestScheduler\DeadlockError: Deadlock detected: no active coroutines, 1 coroutines in waiting in %s:%d
+Stack trace:
+#0 {main}
+ thrown in %s on line %d
diff --git a/ext/test_scheduler/tests/084_fault_objects_store_iterator_new.phpt b/ext/test_scheduler/tests/084_fault_objects_store_iterator_new.phpt
new file mode 100644
index 000000000000..856fff4f1d73
--- /dev/null
+++ b/ext/test_scheduler/tests/084_fault_objects_store_iterator_new.phpt
@@ -0,0 +1,36 @@
+--TEST--
+test_scheduler: the object store's destructors go on in the driving coroutine when no iterator can be created
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+name} start\n";
+ TestScheduler\await(TestScheduler\spawn(fn() => null));
+ echo "{$this->name} end\n";
+ }
+}
+
+/* Cycles outlive the symbol table: the object store's pass destroys them. */
+foreach (['x', 'y'] as $name) {
+ $waiter = new Waiter($name);
+ $waiter->self = $waiter;
+}
+unset($waiter);
+
+ini_set('test_scheduler.fail_new_coroutine', 1);
+echo "end of script\n";
+?>
+--EXPECT--
+end of script
+x start
+x end
+y start
+y end
diff --git a/ext/test_scheduler/tests/085_fault_objects_store_iterator_enqueue.phpt b/ext/test_scheduler/tests/085_fault_objects_store_iterator_enqueue.phpt
new file mode 100644
index 000000000000..47a3c23fac26
--- /dev/null
+++ b/ext/test_scheduler/tests/085_fault_objects_store_iterator_enqueue.phpt
@@ -0,0 +1,41 @@
+--TEST--
+test_scheduler: the object store's destructors go on in the driving coroutine when their iterator cannot be queued
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+name} start\n";
+ TestScheduler\await(TestScheduler\spawn(function () {
+ echo "{$this->name} spawned\n";
+ }));
+ echo "{$this->name} end\n";
+ }
+}
+
+/* Cycles outlive the symbol table: the object store's pass destroys them. */
+foreach (['x', 'y'] as $name) {
+ $waiter = new Waiter($name);
+ $waiter->self = $waiter;
+}
+unset($waiter);
+
+/* The spawn is the first queued coroutine, the iterator the second. */
+ini_set('test_scheduler.fail_enqueue', 2);
+echo "end of script\n";
+?>
+--EXPECT--
+end of script
+x start
+x spawned
+x end
+y start
+y spawned
+y end