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..d3fe830b56c9
--- /dev/null
+++ b/Zend/zend_async_API.c
@@ -0,0 +1,730 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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;
+}
+
+static MUTEX_T scheduler_mutex = NULL;
+
+void zend_async_globals_ctor(void)
+{
+ scheduler_mutex = tsrm_mutex_alloc();
+
+#ifdef ZTS
+ 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;
+ }
+
+ tsrm_mutex_lock(scheduler_mutex);
+
+ /* A scheduler is registered once per process. */
+ if (scheduler_module_name != NULL) {
+ const char *owner = scheduler_module_name;
+
+ tsrm_mutex_unlock(scheduler_mutex);
+ 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);
+
+ tsrm_mutex_unlock(scheduler_mutex);
+
+ 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)
+{
+ tsrm_mutex_lock(scheduler_mutex);
+
+ 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;
+
+ tsrm_mutex_unlock(scheduler_mutex);
+}
+
+void zend_async_api_shutdown(void)
+{
+ zend_async_globals_dtor();
+ zend_async_scheduler_unregister();
+ internal_context_keys_shutdown();
+
+ if (scheduler_mutex != NULL) {
+ tsrm_mutex_free(scheduler_mutex);
+ scheduler_mutex = NULL;
+ }
+}
+
+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..cdcd82e5fd59
--- /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 (used when the coroutine and its object live in different
+ * allocations, e.g. the PHP-scheduler bridge handle). */
+#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 (a PHP scheduler). */
+#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 cb48e6234371..b00eeabf87a9 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"
@@ -179,6 +180,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);
@@ -206,19 +209,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) {
@@ -247,21 +237,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..7bbdf46bdae3 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,310 @@ 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)) {
+ 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 +1093,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 +1126,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 +1147,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 +1199,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 +1310,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 +1346,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 +1419,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 +1462,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 f78567cfaa74..8a1bc5bed803 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/Zend/zend_scheduler_hook.h b/Zend/zend_scheduler_hook.h
new file mode 100644
index 000000000000..84aadceb2e5e
--- /dev/null
+++ b/Zend/zend_scheduler_hook.h
@@ -0,0 +1,37 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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_SCHEDULER_HOOK_H
+#define ZEND_SCHEDULER_HOOK_H
+
+#include "zend.h"
+
+BEGIN_EXTERN_C()
+
+/* Registers the Async\SchedulerHook classes. Called once from the engine
+ * startup. */
+void zend_register_scheduler_hook(void);
+
+/* Releases the PHP scheduler handlers held for the current request. Called
+ * before the executor shutdown (the handler container owns objects the store
+ * would otherwise report as leaked); a no-op when no PHP scheduler was
+ * registered. */
+void zend_scheduler_hook_request_shutdown(void);
+
+/* Frees the coroutine-handle map after the executor shutdown, once every
+ * coroutine object (and with it its handle) is gone. */
+void zend_scheduler_hook_map_shutdown(void);
+
+END_EXTERN_C()
+
+#endif /* ZEND_SCHEDULER_HOOK_H */
diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php
new file mode 100644
index 000000000000..f2237ea53a40
--- /dev/null
+++ b/Zend/zend_scheduler_hook.stub.php
@@ -0,0 +1,111 @@
+. |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Authors: Edmond |
+ +----------------------------------------------------------------------+
+*/
+
+/*
+ * The PHP face of the Async Core: the scheduler bridge and the userland
+ * context class. Built into php-src but optional (--enable-async-scheduler-hook).
+ *
+ * Async\SchedulerHook::register() lets a scheduler written in PHP fill the
+ * engine's scheduler slots with plain callables. Each slot is backed by a
+ * C thunk that forwards to the bound Async\Scheduler method.
+ *
+ * Everything is keyed by the scheduler's own coroutine objects: the bridge
+ * mints a C-visible handle (zend_coroutine_t) per object, and the execution
+ * context behind a coroutine is engine-internal — there is no public
+ * Continuation. The scheduler acts through the mandate: three real closures
+ * over internal functions registered nowhere, handed to the factory once
+ * (bindEntry / switchTo / currentCoroutine). No other code can obtain them.
+ *
+ * Async\Context is the engine's storage (zend_async_context_t) behind a
+ * class this extension registers: the core owns the layout and operations,
+ * the extension owns the PHP surface and the factory slot.
+ */
+
+#ifdef HAVE_CONFIG_H
+#include
+#endif
+
+#include "php.h"
+#include "zend_async_API.h"
+#include "zend_fibers.h"
+#include "zend_exceptions.h"
+#include "zend_closures.h"
+#include "zend_execute.h"
+#include "zend_ini.h"
+#include "zend_interfaces.h"
+
+#include "php_async_scheduler_hook.h"
+#include "async_scheduler_hook_arginfo.h"
+
+/* The Async\SchedulerHook bridge hooks, addressed by index. */
+typedef enum {
+ PHP_ASYNC_HOOK_LAUNCH = 0,
+ PHP_ASYNC_HOOK_SHUTDOWN,
+ PHP_ASYNC_HOOK_INTERCEPT_FIBER,
+ PHP_ASYNC_HOOK_ENQUEUE,
+ PHP_ASYNC_HOOK_SUSPEND,
+ PHP_ASYNC_HOOK_CANCEL,
+ PHP_ASYNC_HOOK_DEFER,
+ PHP_ASYNC_HOOK_COUNT
+} php_async_hook_id;
+
+/* One bound Async\Scheduler method. `set` distinguishes "provided" from "absent". */
+typedef struct {
+ bool set;
+ zend_fcall_info fci;
+ zend_fcall_info_cache fcc;
+} php_async_hook_t;
+
+ZEND_BEGIN_MODULE_GLOBALS(async_scheduler_hook)
+ bool active;
+ zend_string *module;
+ /* The registered Async\Scheduler instance; one ref held for the request.
+ * The bound hook methods borrow this object. */
+ zend_object *scheduler;
+ /* The coroutine object the scheduler last returned from the suspend hook:
+ * the single way the engine learns the current coroutine. Not exposed as a
+ * PHP API (that is the scheduler's business); kept for the core. */
+ zend_object *current_coroutine;
+ php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT];
+ZEND_END_MODULE_GLOBALS(async_scheduler_hook)
+
+ZEND_DECLARE_MODULE_GLOBALS(async_scheduler_hook)
+
+#define ASH_G(v) ZEND_MODULE_GLOBALS_ACCESSOR(async_scheduler_hook, v)
+
+/* The Async\Scheduler method name for each hook (lower-cased, as stored in the
+ * class function table), indexed by php_async_hook_id. */
+static const struct {
+ const char *name;
+ size_t len;
+} php_async_hook_methods[PHP_ASYNC_HOOK_COUNT] = {
+ [PHP_ASYNC_HOOK_LAUNCH] = { ZEND_STRL("onlaunch") },
+ [PHP_ASYNC_HOOK_SHUTDOWN] = { ZEND_STRL("onshutdown") },
+ [PHP_ASYNC_HOOK_INTERCEPT_FIBER] = { ZEND_STRL("onfiber") },
+ [PHP_ASYNC_HOOK_ENQUEUE] = { ZEND_STRL("onenqueue") },
+ [PHP_ASYNC_HOOK_SUSPEND] = { ZEND_STRL("onsuspend") },
+ /* cancel is the same PHP hook as enqueue: "make runnable, with an error". */
+ [PHP_ASYNC_HOOK_CANCEL] = { ZEND_STRL("onenqueue") },
+ [PHP_ASYNC_HOOK_DEFER] = { ZEND_STRL("ondefer") },
+};
+
+static zend_class_entry *async_ce_Scheduler;
+
+#define PHP_ASYNC_HOOK(id) (&ASH_G(hooks)[(id)])
+
+/* The bridge's own coroutine map: object handle -> php_coroutine_t*. A PHP
+ * scheduler defines the coroutine class itself, so the bridge cannot embed
+ * the coroutine in the object and keeps this map as its way back from one;
+ * it backs the bridge's coroutine_from_object slot. No references are held:
+ * a handle dies with its object. */
+ZEND_TLS HashTable *php_async_coroutines = NULL;
+
+typedef struct _php_coroutine_s php_coroutine_t;
+
+/* Every method is required by the Async\Scheduler interface, so the lookup
+ * cannot fail. */
+static void php_async_hook_bind(zend_object *scheduler, php_async_hook_id id)
+{
+ php_async_hook_t *hook = PHP_ASYNC_HOOK(id);
+ zend_function *fn = zend_hash_str_find_ptr(&scheduler->ce->function_table,
+ php_async_hook_methods[id].name, php_async_hook_methods[id].len);
+
+ ZEND_ASSERT(fn != NULL && "Async\\Scheduler requires every hook method");
+
+ memset(&hook->fci, 0, sizeof(hook->fci));
+ memset(&hook->fcc, 0, sizeof(hook->fcc));
+
+ hook->fci.size = sizeof(hook->fci);
+ hook->fci.object = scheduler;
+ ZVAL_UNDEF(&hook->fci.function_name);
+
+ hook->fcc.function_handler = fn;
+ hook->fcc.object = scheduler;
+ hook->fcc.called_scope = scheduler->ce;
+
+ hook->set = true;
+}
+
+/* Call a stored hook with `argc` prepared arguments; result in `retval`
+ * (caller owns it). Returns false when the hook is absent or the call fails. */
+static bool php_async_hook_call(php_async_hook_t *hook, uint32_t argc, zval *argv, zval *retval)
+{
+ if (!hook->set) {
+ ZVAL_UNDEF(retval);
+ return false;
+ }
+
+ hook->fci.param_count = argc;
+ hook->fci.params = argv;
+ hook->fci.retval = retval;
+
+ /* A hook invocation IS scheduler code: switches inside it go directly
+ * instead of routing back through the hooks. */
+ const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true;
+
+ const bool ok = zend_call_function(&hook->fci, &hook->fcc) == SUCCESS && !EG(exception);
+
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context;
+
+ return ok;
+}
+
+/* For hooks whose bool return is data (enqueue: accepted or not). A thrown
+ * exception also yields false, distinguished by EG(exception). */
+static bool php_async_hook_call_bool(php_async_hook_id id, uint32_t argc, zval *argv)
+{
+ zval retval;
+
+ if (!php_async_hook_call(PHP_ASYNC_HOOK(id), argc, argv, &retval)) {
+ return false;
+ }
+
+ const bool ok = zend_is_true(&retval);
+ zval_ptr_dtor(&retval);
+ return ok;
+}
+
+/* For void hooks: the only failure channel is an exception. Returns whether
+ * the call completed without one. */
+static bool php_async_hook_call_void(php_async_hook_id id, uint32_t argc, zval *argv)
+{
+ zval retval;
+
+ const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(id), argc, argv, &retval);
+
+ zval_ptr_dtor(&retval);
+ return ok;
+}
+
+/////////////////////////////////////////////////////////////////////
+/// The coroutine handle
+/////////////////////////////////////////////////////////////////////
+
+/*
+ * The C-visible zend_coroutine_t the bridge mints for a scheduler's
+ * coroutine object, together with the execution context behind it. The
+ * object is a plain PHP object, so the handle lives in its own allocation
+ * and reaches the object through a stored pointer (OBJ_REF). The registry
+ * maps the object to its handle so every path shares one identity.
+ *
+ * Lifetime rides on the object: minting a handle swaps the object's handlers
+ * for a copy whose free_obj destroys the handle before the original free_obj
+ * runs. The registry holds no reference — an adopted fiber pins the object
+ * itself (zend_fiber_adopt takes one), so a fiber's raw pointer to the handle
+ * cannot outlive it.
+ */
+struct _php_coroutine_s {
+ zend_coroutine_t coro;
+ zend_object *object;
+ /* The execution context of this coroutine. Minted on the first switch
+ * into a coroutine that has a body; a main coroutine borrows the
+ * engine's own context instead (captured, never owned). */
+ zend_fiber_context *ctx;
+ bool ctx_captured;
+ /* The context that switched in last: completion returns there (symmetric
+ * model — the body's result belongs to the final switcher). */
+ zend_fiber_context *resumed_from;
+ /* The PHP body bound through the bindEntry capability. */
+ zend_fcall_info fci;
+ zend_fcall_info_cache fcc;
+ zval result;
+ zend_vm_stack vm_stack; /* captured when the body returns */
+ /* A cancellation the C side must deliver itself (a graceful exit is not a
+ * Throwable, so it cannot ride the PHP onEnqueue channel). Thrown when
+ * this coroutine's suspend returns. Owned. */
+ zend_object *pending_error;
+};
+
+/* The context tag: tells the bridge's contexts apart in fiber-aware tooling. */
+static int php_async_coroutine_kind;
+
+typedef struct {
+ zend_object_handlers handlers;
+ const zend_object_handlers *original;
+} php_async_handlers_wrapper_t;
+
+/* Wrapped handler tables, one per original table (i.e. per coroutine
+ * class). Process-lifetime: objects carrying a wrapper may outlive any
+ * request-local storage. */
+static HashTable *php_async_wrapped_handlers = NULL;
+static MUTEX_T php_async_wrapped_handlers_mutex = NULL;
+
+/* The bridge's map: the coroutine of an object, or NULL when the object is
+ * not one of the scheduler's coroutines. This is the coroutine_from_object
+ * slot. */
+static zend_coroutine_t *php_async_coroutine_from_object(zend_object *object)
+{
+ if (php_async_coroutines == NULL) {
+ return NULL;
+ }
+
+ return zend_hash_index_find_ptr(php_async_coroutines, object->handle);
+}
+
+static void php_async_coroutine_free(php_coroutine_t *handle)
+{
+ /* Whoever attached itself to this coroutine (a fiber) gets to let go. */
+ if (handle->coro.extended_dispose != NULL) {
+ handle->coro.extended_dispose(&handle->coro);
+ }
+
+ zend_async_internal_context_destroy(&handle->coro);
+ zend_async_context_destroy(&handle->coro);
+
+ if (php_async_coroutines != NULL && handle->object != NULL) {
+ zend_hash_index_del(php_async_coroutines, handle->object->handle);
+ }
+
+ if (handle->coro.fcall != NULL) {
+ ZEND_ASYNC_FCALL_FREE(handle->coro.fcall);
+ handle->coro.fcall = NULL;
+ }
+
+ if (handle->vm_stack != NULL) {
+ zend_fiber_vm_stack_free(handle->vm_stack);
+ handle->vm_stack = NULL;
+ }
+
+ if (handle->ctx != NULL && !handle->ctx_captured) {
+ /* A finished context was already destroyed by zend_fiber_switch_context
+ * when control returned (it frees a DEAD context). Only destroy one
+ * that never ran to completion. */
+ if (handle->ctx->status != ZEND_FIBER_STATUS_DEAD) {
+ zend_fiber_destroy_context(handle->ctx);
+ }
+
+ efree(handle->ctx);
+ }
+
+ if (Z_TYPE(handle->fci.function_name) != IS_UNDEF) {
+ zval_ptr_dtor(&handle->fci.function_name);
+ }
+
+ zval_ptr_dtor(&handle->result);
+ zval_ptr_dtor(&handle->coro.result);
+
+ if (handle->coro.exception != NULL) {
+ OBJ_RELEASE(handle->coro.exception);
+ }
+
+ if (handle->pending_error != NULL) {
+ OBJ_RELEASE(handle->pending_error);
+ }
+
+ efree(handle);
+}
+
+/* The C destructor installed on a coroutine object: the handle dies with
+ * the object, then the class's own free_obj runs. */
+static void php_async_coroutine_object_free(zend_object *object)
+{
+ const php_async_handlers_wrapper_t *wrapper =
+ (const php_async_handlers_wrapper_t *) object->handlers;
+ php_coroutine_t *handle = (php_coroutine_t *) php_async_coroutine_from_object(object);
+
+ if (handle != NULL) {
+ php_async_coroutine_free(handle);
+ }
+
+ wrapper->original->free_obj(object);
+}
+
+/* The handle owns references the class's own get_gc cannot see. */
+static HashTable *php_async_coroutine_object_gc(zend_object *object, zval **table, int *num)
+{
+ const php_async_handlers_wrapper_t *wrapper =
+ (const php_async_handlers_wrapper_t *) object->handlers;
+ php_coroutine_t *handle = (php_coroutine_t *) php_async_coroutine_from_object(object);
+
+ zval *orig_table = NULL;
+ int orig_num = 0;
+ HashTable *ht = wrapper->original->get_gc != NULL
+ ? wrapper->original->get_gc(object, &orig_table, &orig_num)
+ : NULL;
+
+ if (handle == NULL) {
+ *table = orig_table;
+ *num = orig_num;
+ return ht;
+ }
+
+ /* The original may have used this same shared buffer: create() resets it,
+ * and copying entry i over slot <= i is safe. */
+ zend_get_gc_buffer *buf = zend_get_gc_buffer_create();
+
+ for (int i = 0; i < orig_num; i++) {
+ zend_get_gc_buffer_add_zval(buf, &orig_table[i]);
+ }
+
+ zend_get_gc_buffer_add_zval(buf, &handle->fci.function_name);
+ zend_get_gc_buffer_add_zval(buf, &handle->result);
+ zend_get_gc_buffer_add_zval(buf, &handle->coro.result);
+
+ if (handle->coro.exception != NULL) {
+ zend_get_gc_buffer_add_obj(buf, handle->coro.exception);
+ }
+
+ if (handle->pending_error != NULL) {
+ zend_get_gc_buffer_add_obj(buf, handle->pending_error);
+ }
+
+ if (handle->coro.context != NULL) {
+ zend_get_gc_buffer_add_obj(buf, handle->coro.context);
+ }
+
+ if (handle->coro.fcall != NULL) {
+ zend_get_gc_buffer_add_zval(buf, &handle->coro.fcall->fci.function_name);
+
+ for (uint32_t i = 0; i < handle->coro.fcall->fci.param_count; i++) {
+ zend_get_gc_buffer_add_zval(buf, &handle->coro.fcall->fci.params[i]);
+ }
+
+ if (handle->coro.fcall->fci.named_params != NULL) {
+ zend_get_gc_buffer_add_ht(buf, handle->coro.fcall->fci.named_params);
+ }
+ }
+
+ /* Embedded, not a collectable array: report the values, not the table. */
+ zval *zv;
+ ZEND_HASH_FOREACH_VAL(&handle->coro.internal_context, zv) {
+ zend_get_gc_buffer_add_zval(buf, zv);
+ } ZEND_HASH_FOREACH_END();
+
+ zend_get_gc_buffer_use(buf, table, num);
+
+ return ht;
+}
+
+static void php_async_install_object_free(zend_object *object)
+{
+ if (object->handlers->free_obj == php_async_coroutine_object_free) {
+ return;
+ }
+
+ tsrm_mutex_lock(php_async_wrapped_handlers_mutex);
+
+ if (php_async_wrapped_handlers == NULL) {
+ php_async_wrapped_handlers = pemalloc(sizeof(HashTable), 1);
+ zend_hash_init(php_async_wrapped_handlers, 8, NULL, NULL, 1);
+ }
+
+ php_async_handlers_wrapper_t *wrapper = zend_hash_index_find_ptr(
+ php_async_wrapped_handlers, (zend_ulong) (uintptr_t) object->handlers);
+
+ if (wrapper == NULL) {
+ wrapper = pemalloc(sizeof(*wrapper), 1);
+ wrapper->handlers = *object->handlers;
+ wrapper->handlers.free_obj = php_async_coroutine_object_free;
+ wrapper->handlers.get_gc = php_async_coroutine_object_gc;
+ wrapper->original = object->handlers;
+
+ zend_hash_index_add_new_ptr(php_async_wrapped_handlers,
+ (zend_ulong) (uintptr_t) object->handlers, wrapper);
+ }
+
+ tsrm_mutex_unlock(php_async_wrapped_handlers_mutex);
+
+ object->handlers = &wrapper->handlers;
+}
+
+/* Find or mint the handle for a coroutine object. Every path (the
+ * current-coroutine record, fiber adoption, bindEntry, the resolver slot)
+ * shares this one map, so a coroutine object has exactly one handle. */
+static php_coroutine_t *php_async_coroutine_handle(zend_object *object)
+{
+ php_coroutine_t *found = (php_coroutine_t *) php_async_coroutine_from_object(object);
+
+ if (found != NULL) {
+ return found;
+ }
+
+ if (php_async_coroutines == NULL) {
+ ALLOC_HASHTABLE(php_async_coroutines);
+ zend_hash_init(php_async_coroutines, 8, NULL, NULL, false);
+ }
+
+ php_coroutine_t *handle = ecalloc(1, sizeof(*handle));
+
+ handle->object = object;
+ handle->coro.flags = ZEND_COROUTINE_F_OBJ_REF;
+ handle->coro.object_offset = offsetof(php_coroutine_t, object);
+ ZVAL_UNDEF(&handle->coro.result);
+ ZVAL_UNDEF(&handle->fci.function_name);
+ ZVAL_UNDEF(&handle->result);
+ zend_async_internal_context_init(&handle->coro);
+
+ php_async_install_object_free(object);
+ zend_hash_index_update_ptr(php_async_coroutines, object->handle, handle);
+
+ return handle;
+}
+
+/* Record `current` as the current coroutine: one reference held by the
+ * handlers container, and the C-visible handle in the engine's slot. */
+static void php_async_set_current(zend_object *current)
+{
+ if (current != NULL) {
+ GC_ADDREF(current);
+ }
+
+ if (ASH_G(current_coroutine) != NULL) {
+ OBJ_RELEASE(ASH_G(current_coroutine));
+ }
+
+ ASH_G(current_coroutine) = current;
+ ZEND_ASYNC_CURRENT_COROUTINE =
+ current != NULL ? &php_async_coroutine_handle(current)->coro : NULL;
+}
+
+/* Record the coroutine the suspend/launch hook returned as the current one:
+ * the single way the engine learns it. */
+static void php_async_record_current(bool ok, zval *retval)
+{
+ php_async_set_current((ok && Z_TYPE_P(retval) == IS_OBJECT) ? Z_OBJ_P(retval) : NULL);
+}
+
+/////////////////////////////////////////////////////////////////////
+/// The execution context behind a coroutine
+/////////////////////////////////////////////////////////////////////
+
+/* The bottom frame of a coroutine's VM stack. Nameless on purpose: a frame
+ * with no function name is a dummy frame, and backtraces skip it. */
+static zend_function php_async_root_function = { ZEND_INTERNAL_FUNCTION };
+
+/* Set by the switcher before a first entry: a fresh context has no other way
+ * to reach its handle. */
+ZEND_TLS php_coroutine_t *php_async_starting = NULL;
+
+/* First-run trampoline: install a VM stack, run the body, return to the last
+ * switcher (the engine's trampoline marks the context DEAD). */
+static ZEND_STACK_ALIGNED void php_async_coroutine_ctx_entry(zend_fiber_transfer *transfer)
+{
+ php_coroutine_t *handle = php_async_starting;
+
+ transfer->context = NULL;
+
+ /* A first entry with an error cancels the coroutine before it ran: the
+ * body never starts, and the error travels back to the switcher. */
+ if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) {
+ transfer->context = handle->resumed_from;
+ return;
+ }
+
+ zval_ptr_dtor(&transfer->value);
+ ZVAL_UNDEF(&transfer->value);
+ transfer->flags = 0;
+
+ EG(vm_stack) = NULL;
+
+ zend_first_try {
+ zend_fiber_vm_stack_start(handle->ctx, &php_async_root_function);
+
+ /* This flow IS the coroutine while the body runs, and the body is
+ * application code, not scheduler machinery. */
+ php_async_set_current(handle->object);
+ ZEND_COROUTINE_SET_STATUS(&handle->coro, ZEND_COROUTINE_STATUS_RUNNING);
+
+ const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false;
+
+ if (handle->coro.internal_entry != NULL) {
+ handle->coro.internal_entry();
+ } else if (handle->coro.fcall != NULL) {
+ handle->coro.fcall->fci.retval = &handle->coro.result;
+ zend_call_function(&handle->coro.fcall->fci, &handle->coro.fcall->fci_cache);
+ } else {
+ handle->fci.retval = &handle->result;
+ zend_call_function(&handle->fci, &handle->fcc);
+
+ zval_ptr_dtor(&handle->fci.function_name);
+ ZVAL_UNDEF(&handle->fci.function_name);
+ }
+
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context;
+ ZEND_COROUTINE_SET_STATUS(&handle->coro, ZEND_COROUTINE_STATUS_FINISHED);
+
+ if (UNEXPECTED(EG(exception))) {
+ zend_object *ex = EG(exception);
+ GC_ADDREF(ex);
+ zend_clear_exception();
+ transfer->flags |= ZEND_FIBER_TRANSFER_FLAG_ERROR;
+ ZVAL_OBJ(&transfer->value, ex);
+ } else {
+ if (Z_ISUNDEF(handle->result)) {
+ ZVAL_NULL(&handle->result);
+ }
+
+ ZVAL_COPY_VALUE(&transfer->value, &handle->result);
+ ZVAL_UNDEF(&handle->result);
+ }
+ } zend_catch {
+ transfer->flags |= ZEND_FIBER_TRANSFER_FLAG_BAILOUT;
+ } zend_end_try();
+
+ handle->vm_stack = EG(vm_stack);
+
+ transfer->context = handle->resumed_from;
+}
+
+/* Symmetric switch into the coroutine's context. A non-null `error` is
+ * delivered instead of a value: it is thrown from the switchTo() call the
+ * target is suspended in (or, on a first entry, finishes the coroutine
+ * without starting the body). */
+static void php_async_switch_to(
+ php_coroutine_t *handle, zval *send_value, zend_object *error, zval *return_value)
+{
+ php_async_starting = handle;
+ handle->resumed_from = EG(current_fiber_context);
+
+ /* The switch suspends this flow; its identity is restored when control
+ * comes back, whatever ran in between. */
+ zend_object *previous_current = ASH_G(current_coroutine);
+
+ if (previous_current != NULL) {
+ GC_ADDREF(previous_current);
+ }
+
+ zend_fiber_transfer transfer = { .context = handle->ctx, .flags = 0 };
+
+ if (error != NULL) {
+ GC_ADDREF(error);
+ ZVAL_OBJ(&transfer.value, error);
+ transfer.flags = ZEND_FIBER_TRANSFER_FLAG_ERROR;
+ } else if (send_value != NULL) {
+ ZVAL_COPY(&transfer.value, send_value);
+ } else {
+ ZVAL_NULL(&transfer.value);
+ }
+
+ zend_fiber_switch_context(&transfer);
+
+ php_async_set_current(previous_current);
+
+ if (previous_current != NULL) {
+ OBJ_RELEASE(previous_current);
+ }
+
+ /* A first entry cancelled before the body ran leaves the handle CREATED
+ * with a DEAD context: it is finished all the same. */
+ if (handle->ctx != NULL && handle->ctx->status == ZEND_FIBER_STATUS_DEAD
+ && !ZEND_COROUTINE_IS_FINISHED(&handle->coro)) {
+ ZEND_COROUTINE_SET_STATUS(&handle->coro, ZEND_COROUTINE_STATUS_FINISHED);
+ }
+
+ /* Bailout initiated on the other side: re-raise here after our context is
+ * restored. */
+ if (UNEXPECTED(transfer.flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT)) {
+ zend_bailout();
+ }
+
+ /* An exception thrown on the other side surfaces at the switch site. */
+ if (UNEXPECTED(transfer.flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) {
+ zend_throw_exception_internal(Z_OBJ(transfer.value));
+
+ if (return_value != NULL) {
+ ZVAL_NULL(return_value);
+ }
+
+ return;
+ }
+
+ if (return_value != NULL) {
+ ZVAL_COPY_VALUE(return_value, &transfer.value);
+ } else {
+ zval_ptr_dtor(&transfer.value);
+ }
+}
+
+/////////////////////////////////////////////////////////////////////
+/// The mandate — closures over functions registered nowhere
+/////////////////////////////////////////////////////////////////////
+
+/*
+ * The capabilities are real closures over internal functions that exist in
+ * no function table: only the factory receives them, and no other code can
+ * mint or reach them — the capability model the RFC promises.
+ */
+
+/* bindEntry(object $coroutine, callable $entry): void */
+static ZEND_NAMED_FUNCTION(php_async_mandate_bind_entry)
+{
+ zend_object *coroutine_obj;
+ zend_fcall_info fci;
+ zend_fcall_info_cache fcc;
+
+ ZEND_PARSE_PARAMETERS_START(2, 2)
+ Z_PARAM_OBJ(coroutine_obj)
+ Z_PARAM_FUNC(fci, fcc)
+ ZEND_PARSE_PARAMETERS_END();
+
+ php_coroutine_t *handle = php_async_coroutine_handle(coroutine_obj);
+
+ if (handle->coro.internal_entry != NULL || handle->coro.fcall != NULL) {
+ zend_throw_error(NULL, "The engine owns this coroutine's body");
+ RETURN_THROWS();
+ }
+
+ if (Z_TYPE(handle->fci.function_name) != IS_UNDEF || ZEND_COROUTINE_IS_STARTED(&handle->coro)) {
+ zend_throw_error(NULL, "The coroutine already has a body");
+ RETURN_THROWS();
+ }
+
+ handle->fci = fci;
+ handle->fcc = fcc;
+ Z_TRY_ADDREF(handle->fci.function_name);
+}
+
+/* switchTo(object $coroutine, mixed $value = null, ?Throwable $error = null): mixed */
+static ZEND_NAMED_FUNCTION(php_async_mandate_switch_to)
+{
+ zend_object *coroutine_obj;
+ zval *value = NULL;
+ zend_object *error = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 3)
+ Z_PARAM_OBJ(coroutine_obj)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_ZVAL(value)
+ Z_PARAM_OBJ_OF_CLASS_OR_NULL(error, zend_ce_throwable)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (error != NULL && value != NULL && Z_TYPE_P(value) != IS_NULL) {
+ zend_argument_value_error(2,
+ "must be null when an error is delivered: there is nowhere the value could arrive");
+ RETURN_THROWS();
+ }
+
+ php_coroutine_t *handle = (php_coroutine_t *) php_async_coroutine_from_object(coroutine_obj);
+
+ if (handle == NULL) {
+ zend_throw_error(NULL, "The object is not a coroutine the engine knows");
+ RETURN_THROWS();
+ }
+
+ if (UNEXPECTED(ZEND_COROUTINE_IS_FINISHED(&handle->coro)
+ || (handle->ctx != NULL && handle->ctx->status == ZEND_FIBER_STATUS_DEAD))) {
+ zend_throw_error(NULL, "Cannot switch into a finished coroutine");
+ RETURN_THROWS();
+ }
+
+ /* The engine only asserts to != from, and asserts are no-ops in release. */
+ if (UNEXPECTED(handle->ctx == EG(current_fiber_context))) {
+ zend_throw_error(NULL, "Cannot switch into the currently running coroutine");
+ RETURN_THROWS();
+ }
+
+ if (handle->ctx == NULL) {
+ if (handle->coro.internal_entry == NULL && handle->coro.fcall == NULL
+ && Z_TYPE(handle->fci.function_name) == IS_UNDEF) {
+ zend_throw_error(NULL, "The coroutine has no body: bind one with bindEntry()");
+ RETURN_THROWS();
+ }
+
+ handle->ctx = ecalloc(1, sizeof(zend_fiber_context));
+
+ if (zend_fiber_init_context(handle->ctx, &php_async_coroutine_kind,
+ php_async_coroutine_ctx_entry, EG(fiber_stack_size)) == FAILURE) {
+ efree(handle->ctx);
+ handle->ctx = NULL;
+ RETURN_THROWS();
+ }
+ }
+
+ php_async_switch_to(handle, value, error, return_value);
+
+ if (UNEXPECTED(EG(exception))) {
+ RETURN_THROWS();
+ }
+}
+
+/* currentCoroutine(): ?object */
+static ZEND_NAMED_FUNCTION(php_async_mandate_current_coroutine)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ if (ASH_G(current_coroutine) != NULL) {
+ RETURN_OBJ_COPY(ASH_G(current_coroutine));
+ }
+
+ RETURN_NULL();
+}
+
+static zend_internal_function php_async_mandate_functions[3];
+
+static void php_async_mandate_function_init(
+ zend_internal_function *fn, const char *name, zif_handler handler)
+{
+ memset(fn, 0, sizeof(*fn));
+ fn->type = ZEND_INTERNAL_FUNCTION;
+ fn->fn_flags = ZEND_ACC_PUBLIC;
+ fn->function_name = zend_string_init_interned(name, strlen(name), true);
+ fn->handler = handler;
+}
+
+static void php_async_mandate_closure(zval *out, zend_internal_function *fn)
+{
+ zend_create_fake_closure(out, (zend_function *) fn, NULL, NULL, NULL);
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Thunks
+/////////////////////////////////////////////////////////////////////
+
+static bool php_async_thunk_shutdown(void)
+{
+ return php_async_hook_call_void(PHP_ASYNC_HOOK_SHUTDOWN, 0, NULL);
+}
+
+/* A main coroutine never gets a context of its own: it IS the engine's
+ * current flow at the moment it is recorded, so it borrows that context —
+ * switching into it wakes the engine's stack wherever it parked. */
+static void php_async_adopt_main_context(void)
+{
+ php_coroutine_t *main_handle = (php_coroutine_t *) ZEND_ASYNC_MAIN_COROUTINE;
+
+ if (main_handle != NULL && main_handle->ctx == NULL) {
+ main_handle->ctx = EG(current_fiber_context);
+ main_handle->ctx_captured = true;
+ }
+}
+
+/* The launch slot: ask the scheduler for the coroutine the top-level script
+ * runs in, and hand the engine its C handle. */
+static zend_coroutine_t *php_async_thunk_launch(void)
+{
+ zval retval;
+
+ const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 0, NULL, &retval);
+
+ if (!ok || Z_TYPE(retval) != IS_OBJECT) {
+ zval_ptr_dtor(&retval);
+
+ if (!EG(exception)) {
+ zend_throw_error(NULL, "Async\\Scheduler::onLaunch() must return the main coroutine");
+ }
+
+ return NULL;
+ }
+
+ php_async_record_current(true, &retval);
+
+ php_coroutine_t *main_handle = php_async_coroutine_handle(Z_OBJ(retval));
+
+ main_handle->ctx = EG(current_fiber_context);
+ main_handle->ctx_captured = true;
+
+ zval_ptr_dtor(&retval);
+ return &main_handle->coro;
+}
+
+static zend_coroutine_t *php_async_thunk_intercept_fiber(zend_fiber *fiber)
+{
+ zval arg, retval;
+ ZVAL_OBJ(&arg, &fiber->std);
+ GC_ADDREF(&fiber->std);
+
+ const bool ok = php_async_hook_call(
+ PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_INTERCEPT_FIBER), 1, &arg, &retval);
+
+ zval_ptr_dtor(&arg);
+
+ if (!ok || Z_TYPE(retval) != IS_OBJECT) {
+ /* NULL (or any non-object) keeps the fiber on the low-level path. */
+ zval_ptr_dtor(&retval);
+ return NULL;
+ }
+
+ /* The engine binds the fiber to the handle itself (zend_fiber_adopt): it
+ * sets the entry, the fiber back-pointer, and pins the object. */
+ php_coroutine_t *handle = php_async_coroutine_handle(Z_OBJ(retval));
+
+ zval_ptr_dtor(&retval);
+ return &handle->coro;
+}
+
+/* The coroutine's PHP object as a zval, with a borrowed +1 for the call. */
+static void php_async_coroutine_arg(zend_coroutine_t *coro, zval *out)
+{
+ zend_object *object = ZEND_COROUTINE_OBJECT(coro);
+
+ if (object != NULL) {
+ ZVAL_OBJ(out, object);
+ GC_ADDREF(object);
+ } else {
+ ZVAL_NULL(out);
+ }
+}
+
+/* Shared body for enqueue/cancel: onEnqueue(coroutine, ?error). transfer_error
+ * hands over ownership of the error reference. A non-Throwable error (the
+ * graceful/unwind exit markers) cannot cross into PHP: it parks on the handle
+ * and the suspend thunk throws it C-side instead. */
+static bool php_async_thunk_wake(php_async_hook_id id, zend_coroutine_t *coro,
+ zend_object *error, bool transfer_error)
+{
+ php_coroutine_t *handle = (php_coroutine_t *) coro;
+ zval args[2];
+ php_async_coroutine_arg(coro, &args[0]);
+
+ if (error != NULL && !instanceof_function(error->ce, zend_ce_throwable)) {
+ if (!transfer_error) {
+ GC_ADDREF(error);
+ }
+
+ if (handle->pending_error != NULL) {
+ OBJ_RELEASE(handle->pending_error);
+ }
+
+ handle->pending_error = error;
+ error = NULL;
+ }
+
+ if (error != NULL) {
+ ZVAL_OBJ(&args[1], error);
+ if (!transfer_error) {
+ GC_ADDREF(error);
+ }
+ } else {
+ ZVAL_NULL(&args[1]);
+ }
+
+ const bool result = php_async_hook_call_bool(id, 2, args);
+
+ if (result && !ZEND_COROUTINE_IS_STARTED(coro)) {
+ ZEND_COROUTINE_SET_STATUS(coro, ZEND_COROUTINE_STATUS_QUEUED);
+ }
+
+ zval_ptr_dtor(&args[0]);
+ zval_ptr_dtor(&args[1]);
+ return result;
+}
+
+static bool php_async_thunk_enqueue(
+ zend_coroutine_t *coro, zend_object *error, bool transfer_error)
+{
+ return php_async_thunk_wake(PHP_ASYNC_HOOK_ENQUEUE, coro, error, transfer_error);
+}
+
+static bool php_async_thunk_cancel(
+ zend_coroutine_t *coro, zend_object *error, bool transfer_error, const bool is_safely)
+{
+ (void) is_safely; /* the PHP hook set has no "safely" flag */
+
+ ZEND_COROUTINE_SET_CANCELLED(coro);
+
+ return php_async_thunk_wake(PHP_ASYNC_HOOK_CANCEL, coro, error, transfer_error);
+}
+
+/* The end-of-main handover. The main coroutine of the script REALLY finishes
+ * here — whatever runs after the drain (shutdown functions, destructors) is a
+ * NEW main coroutine, and the scheduler must hand it back explicitly. */
+static bool php_async_thunk_suspend_from_main(zval *args)
+{
+ zend_object *old_main = NULL;
+
+ if (ZEND_ASYNC_MAIN_COROUTINE != NULL) {
+ ZEND_COROUTINE_SET_STATUS(ZEND_ASYNC_MAIN_COROUTINE, ZEND_COROUTINE_STATUS_FINISHED);
+ old_main = ZEND_COROUTINE_OBJECT(ZEND_ASYNC_MAIN_COROUTINE);
+ }
+
+ zval retval;
+ const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SUSPEND), 2, args, &retval);
+
+ if (!ok || Z_TYPE(retval) != IS_OBJECT || Z_OBJ(retval) == old_main) {
+ zval_ptr_dtor(&retval);
+
+ if (!EG(exception)) {
+ zend_throw_error(NULL,
+ "Async\\Scheduler::onSuspend(): the end-of-main handover must return a fresh main coroutine");
+ }
+
+ return false;
+ }
+
+ php_async_record_current(true, &retval);
+
+ zend_coroutine_t *main_coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
+ ZEND_COROUTINE_SET_MAIN(main_coroutine);
+ ZEND_COROUTINE_SET_STATUS(main_coroutine, ZEND_COROUTINE_STATUS_RUNNING);
+ ZEND_ASYNC_MAIN_COROUTINE = main_coroutine;
+ php_async_adopt_main_context();
+
+ zval_ptr_dtor(&retval);
+ return !EG(exception);
+}
+
+static bool php_async_thunk_suspend(bool from_main, bool is_bailout)
+{
+ zval args[2];
+ ZVAL_BOOL(&args[0], from_main);
+ ZVAL_BOOL(&args[1], is_bailout);
+
+ if (from_main) {
+ return php_async_thunk_suspend_from_main(args);
+ }
+
+ php_coroutine_t *self = (php_coroutine_t *) ZEND_ASYNC_CURRENT_COROUTINE;
+
+ if (self != NULL && !ZEND_COROUTINE_IS_FINISHED(&self->coro)) {
+ ZEND_COROUTINE_SET_STATUS(&self->coro, ZEND_COROUTINE_STATUS_SUSPENDED);
+ }
+
+ zval retval;
+ const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SUSPEND), 2, args, &retval);
+
+ /* The hook returns the coroutine it switched to; the engine records it as
+ * the current coroutine (not exposed as a PHP API). */
+ php_async_record_current(ok, &retval);
+
+ zval_ptr_dtor(&retval);
+
+ if (self != NULL && !ZEND_COROUTINE_IS_FINISHED(&self->coro)) {
+ ZEND_COROUTINE_SET_STATUS(&self->coro, ZEND_COROUTINE_STATUS_RUNNING);
+
+ /* A C-side cancellation parked on the handle (see pending_error). */
+ if (UNEXPECTED(self->pending_error != NULL)) {
+ zend_object *error = self->pending_error;
+ self->pending_error = NULL;
+
+ if (!EG(exception)) {
+ zend_throw_exception_internal(error);
+ } else {
+ OBJ_RELEASE(error);
+ }
+
+ return false;
+ }
+ }
+
+ return ok && !EG(exception);
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Registration
+/////////////////////////////////////////////////////////////////////
+
+static void php_async_handlers_reset(void)
+{
+ for (php_async_hook_id id = 0; id < PHP_ASYNC_HOOK_COUNT; id++) {
+ PHP_ASYNC_HOOK(id)->set = false;
+ }
+
+ ZEND_ASYNC_CURRENT_COROUTINE = NULL;
+ ZEND_ASYNC_MAIN_COROUTINE = NULL;
+
+ /* The handle map stays: a handle dies with its coroutine object (the
+ * wrapped free_obj), and the executor shutdown is what frees the objects.
+ * zend_scheduler_hook_map_shutdown() sweeps whatever would remain. */
+
+ if (ASH_G(current_coroutine) != NULL) {
+ OBJ_RELEASE(ASH_G(current_coroutine));
+ ASH_G(current_coroutine) = NULL;
+ }
+
+ if (ASH_G(scheduler) != NULL) {
+ OBJ_RELEASE(ASH_G(scheduler));
+ ASH_G(scheduler) = NULL;
+ }
+
+ if (ASH_G(module) != NULL) {
+ zend_string_release(ASH_G(module));
+ ASH_G(module) = NULL;
+ }
+
+ ASH_G(active) = false;
+}
+
+/* Fill `api` with the thunk pointers. The slots the bridge cannot speak for a
+ * PHP scheduler (C coroutine allocation, await, C microtasks, the handler
+ * vectors) stay NULL; their macros degrade gracefully. */
+static void php_async_build_api(zend_async_scheduler_api_t *api)
+{
+ memset(api, 0, sizeof(*api));
+ api->size = sizeof(*api);
+
+ api->launch = php_async_thunk_launch;
+ api->shutdown = php_async_thunk_shutdown;
+ api->intercept_fiber = php_async_thunk_intercept_fiber;
+ api->enqueue_coroutine = php_async_thunk_enqueue;
+ api->suspend = php_async_thunk_suspend;
+ api->cancel = php_async_thunk_cancel;
+
+ /* Not a hook: the bridge answers this one from its own map, so the core
+ * never has to know how a PHP scheduler's coroutine objects are laid out. */
+ api->coroutine_from_object = php_async_coroutine_from_object;
+}
+
+ZEND_METHOD(Async_SchedulerHook, register)
+{
+ zend_string *module;
+ zend_fcall_info factory_fci;
+ zend_fcall_info_cache factory_fcc;
+
+ ZEND_PARSE_PARAMETERS_START(2, 2)
+ Z_PARAM_STR(module)
+ Z_PARAM_FUNC(factory_fci, factory_fcc)
+ ZEND_PARSE_PARAMETERS_END();
+
+ /* A scheduler is registered once per process — by a C extension or by
+ * PHP, whichever comes first. */
+ if (zend_async_is_enabled()) {
+ zend_throw_error(NULL, "A scheduler is already registered");
+ RETURN_THROWS();
+ }
+
+ /* The factory receives the mandate and returns the scheduler, so the
+ * scheduler is constructed already holding its capabilities. For a PHP
+ * scheduler this call IS the launch moment: the engine's own launch point
+ * has already passed by the time userland code runs. */
+ zval args[3], retval;
+ php_async_mandate_closure(&args[0], &php_async_mandate_functions[0]);
+ php_async_mandate_closure(&args[1], &php_async_mandate_functions[1]);
+ php_async_mandate_closure(&args[2], &php_async_mandate_functions[2]);
+
+ factory_fci.param_count = 3;
+ factory_fci.params = args;
+ factory_fci.retval = &retval;
+
+ const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true;
+ const zend_result called = zend_call_function(&factory_fci, &factory_fcc);
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context;
+
+ zval_ptr_dtor(&args[0]);
+ zval_ptr_dtor(&args[1]);
+ zval_ptr_dtor(&args[2]);
+
+ if (called != SUCCESS || EG(exception)) {
+ zval_ptr_dtor(&retval);
+ RETURN_THROWS();
+ }
+
+ if (Z_TYPE(retval) != IS_OBJECT
+ || !instanceof_function(Z_OBJCE(retval), async_ce_Scheduler)) {
+ zval_ptr_dtor(&retval);
+ zend_type_error("Async\\SchedulerHook::register(): Argument #2 ($factory) must return an instance of Async\\Scheduler");
+ RETURN_THROWS();
+ }
+
+ zend_object *scheduler = Z_OBJ(retval);
+
+ for (php_async_hook_id id = 0; id < PHP_ASYNC_HOOK_COUNT; id++) {
+ php_async_hook_bind(scheduler, id);
+ }
+
+ /* The handlers container takes over the factory's reference. */
+ ASH_G(scheduler) = scheduler;
+ ASH_G(module) = zend_string_copy(module);
+ ASH_G(active) = true;
+
+ /* The C slots are process-wide and set once; per-thread state is the
+ * handlers container above. A later request (any thread) only re-binds
+ * its handlers and launches. */
+ const char *owner = zend_async_get_scheduler_module();
+ bool registered_now = false;
+
+ if (owner == NULL) {
+ zend_async_scheduler_api_t api;
+ php_async_build_api(&api);
+
+ registered_now = zend_async_scheduler_register(ZSTR_VAL(module), &api);
+
+ if (!registered_now) {
+ /* Lost a registration race — acceptable if to ourselves. */
+ owner = zend_async_get_scheduler_module();
+ }
+ }
+
+ if (!registered_now && (owner == NULL || strcmp(owner, ZSTR_VAL(module)) != 0)) {
+ php_async_handlers_reset();
+ zend_throw_error(NULL, "Async\\SchedulerHook::register(): the engine refused the scheduler");
+ RETURN_THROWS();
+ }
+
+ if (!ZEND_ASYNC_SCHEDULER_LAUNCH()) {
+ php_async_handlers_reset();
+
+ if (registered_now) {
+ zend_async_scheduler_unregister();
+ }
+
+ ZEND_ASYNC_DEACTIVATE;
+ RETURN_THROWS();
+ }
+}
+
+ZEND_METHOD(Async_SchedulerHook, getModule)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ const char *module = zend_async_get_scheduler_module();
+
+ if (module == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_STRING(module);
+}
+
+ZEND_METHOD(Async_SchedulerHook, defer)
+{
+ zval *task;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_ZVAL(task)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_DEFER)->set) {
+ zend_throw_error(NULL, "No scheduler is registered");
+ RETURN_THROWS();
+ }
+
+ zval arg;
+ ZVAL_COPY(&arg, task);
+ php_async_hook_call_void(PHP_ASYNC_HOOK_DEFER, 1, &arg);
+ zval_ptr_dtor(&arg);
+
+ if (UNEXPECTED(EG(exception))) {
+ RETURN_THROWS();
+ }
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Async\Context — the PHP surface over the engine's storage
+/////////////////////////////////////////////////////////////////////
+
+static zend_class_entry *async_ce_Context;
+static zend_object_handlers async_context_handlers;
+
+static zend_object *async_context_create_object(zend_class_entry *ce)
+{
+ zend_async_context_t *context = zend_object_alloc(sizeof(zend_async_context_t), ce);
+
+ zend_object_std_init(&context->std, ce);
+ object_properties_init(&context->std, ce);
+ context->std.handlers = &async_context_handlers;
+
+ zend_async_context_tables_init(context);
+
+ return &context->std;
+}
+
+/* The factory the core mints instances through (zend_async_new_context_fn). */
+static zend_object *async_context_new(void)
+{
+ return async_context_create_object(async_ce_Context);
+}
+
+static void async_context_free_object(zend_object *object)
+{
+ zend_async_context_tables_destroy(ZEND_ASYNC_CONTEXT_FROM_OBJ(object));
+ zend_object_std_dtor(object);
+}
+
+static HashTable *async_context_object_gc(zend_object *object, zval **table, int *num)
+{
+ zend_get_gc_buffer *buf = zend_get_gc_buffer_create();
+
+ zend_async_context_entry_gc(ZEND_ASYNC_CONTEXT_FROM_OBJ(object), buf);
+
+ zend_get_gc_buffer_use(buf, table, num);
+ return NULL;
+}
+
+ZEND_METHOD(Async_Context, find)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OR_STR(okey, skey)
+ ZEND_PARSE_PARAMETERS_END();
+
+ const zval *value = zend_async_context_entry_find(
+ ZEND_ASYNC_CONTEXT_FROM_OBJ(Z_OBJ_P(ZEND_THIS)), skey, okey);
+
+ if (value == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_COPY(value);
+}
+
+ZEND_METHOD(Async_Context, has)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OR_STR(okey, skey)
+ ZEND_PARSE_PARAMETERS_END();
+
+ RETURN_BOOL(zend_async_context_entry_find(
+ ZEND_ASYNC_CONTEXT_FROM_OBJ(Z_OBJ_P(ZEND_THIS)), skey, okey)
+ != NULL);
+}
+
+ZEND_METHOD(Async_Context, set)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+ zval *value;
+
+ ZEND_PARSE_PARAMETERS_START(2, 2)
+ Z_PARAM_OBJ_OR_STR(okey, skey)
+ Z_PARAM_ZVAL(value)
+ ZEND_PARSE_PARAMETERS_END();
+
+ zend_async_context_entry_set(
+ ZEND_ASYNC_CONTEXT_FROM_OBJ(Z_OBJ_P(ZEND_THIS)), skey, okey, value);
+
+ RETURN_OBJ_COPY(Z_OBJ_P(ZEND_THIS));
+}
+
+ZEND_METHOD(Async_Context, unset)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OR_STR(okey, skey)
+ ZEND_PARSE_PARAMETERS_END();
+
+ RETURN_BOOL(zend_async_context_entry_unset(
+ ZEND_ASYNC_CONTEXT_FROM_OBJ(Z_OBJ_P(ZEND_THIS)), skey, okey));
+}
+
+ZEND_FUNCTION(Async_get_context)
+{
+ zend_object *coroutine_obj = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(0, 1)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJ_OR_NULL(coroutine_obj)
+ ZEND_PARSE_PARAMETERS_END();
+
+ zend_coroutine_t *coroutine = NULL;
+
+ /* Only the explicit form needs the scheduler: the current coroutine the
+ * engine already knows, so the common call resolves with no slot at all. */
+ if (coroutine_obj != NULL) {
+ coroutine = zend_async_coroutine_from_object(coroutine_obj);
+
+ if (coroutine == NULL) {
+ zend_argument_value_error(1, "must be a coroutine object known to the scheduler");
+ RETURN_THROWS();
+ }
+ }
+
+ zend_object *store = zend_async_context_get(coroutine);
+
+ if (store == NULL) {
+ zend_throw_error(NULL, "Async\\get_context(): no coroutine is running");
+ RETURN_THROWS();
+ }
+
+ RETURN_OBJ_COPY(store);
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Module
+/////////////////////////////////////////////////////////////////////
+
+static PHP_GINIT_FUNCTION(async_scheduler_hook)
+{
+#if defined(COMPILE_DL_ASYNC_SCHEDULER_HOOK) && defined(ZTS)
+ ZEND_TSRMLS_CACHE_UPDATE();
+#endif
+ memset(async_scheduler_hook_globals, 0, sizeof(*async_scheduler_hook_globals));
+}
+
+PHP_MINIT_FUNCTION(async_scheduler_hook)
+{
+ php_async_wrapped_handlers_mutex = tsrm_mutex_alloc();
+
+ async_ce_Scheduler = register_class_Async_Scheduler();
+ register_class_Async_SchedulerHook();
+
+ async_ce_Context = register_class_Async_Context();
+ async_ce_Context->create_object = async_context_create_object;
+
+ memcpy(&async_context_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers));
+ async_context_handlers.offset = offsetof(zend_async_context_t, std);
+ async_context_handlers.free_obj = async_context_free_object;
+ async_context_handlers.get_gc = async_context_object_gc;
+ async_context_handlers.clone_obj = NULL;
+
+ zend_async_new_context_fn = async_context_new;
+
+ php_async_mandate_function_init(
+ &php_async_mandate_functions[0], "bindEntry", php_async_mandate_bind_entry);
+ php_async_mandate_function_init(
+ &php_async_mandate_functions[1], "switchTo", php_async_mandate_switch_to);
+ php_async_mandate_function_init(&php_async_mandate_functions[2], "currentCoroutine",
+ php_async_mandate_current_coroutine);
+
+ return SUCCESS;
+}
+
+PHP_MSHUTDOWN_FUNCTION(async_scheduler_hook)
+{
+ zend_async_new_context_fn = NULL;
+
+ if (php_async_wrapped_handlers != NULL) {
+ php_async_handlers_wrapper_t *wrapper;
+
+ ZEND_HASH_FOREACH_PTR(php_async_wrapped_handlers, wrapper)
+ {
+ pefree(wrapper, 1);
+ }
+ ZEND_HASH_FOREACH_END();
+
+ zend_hash_destroy(php_async_wrapped_handlers);
+ pefree(php_async_wrapped_handlers, 1);
+ php_async_wrapped_handlers = NULL;
+ }
+
+ if (php_async_wrapped_handlers_mutex != NULL) {
+ tsrm_mutex_free(php_async_wrapped_handlers_mutex);
+ php_async_wrapped_handlers_mutex = NULL;
+ }
+
+ return SUCCESS;
+}
+
+/* Runs before the executor shutdown: the handler container owns objects the
+ * store would otherwise report as leaked. */
+PHP_RSHUTDOWN_FUNCTION(async_scheduler_hook)
+{
+ if (ASH_G(active)) {
+ php_async_handlers_reset();
+ ZEND_ASYNC_DEACTIVATE;
+ }
+
+ return SUCCESS;
+}
+
+/* Runs after the executor shutdown, once every coroutine object (and with it
+ * its handle) is gone. */
+static ZEND_MODULE_POST_ZEND_DEACTIVATE_D(async_scheduler_hook)
+{
+ if (php_async_coroutines == NULL) {
+ return SUCCESS;
+ }
+
+ HashTable *registry = php_async_coroutines;
+ php_async_coroutines = NULL;
+
+ php_coroutine_t *handle;
+
+ ZEND_HASH_FOREACH_PTR(registry, handle)
+ {
+ php_async_coroutine_free(handle);
+ }
+ ZEND_HASH_FOREACH_END();
+
+ zend_hash_destroy(registry);
+ FREE_HASHTABLE(registry);
+
+ return SUCCESS;
+}
+
+zend_module_entry async_scheduler_hook_module_entry = {
+ STANDARD_MODULE_HEADER,
+ "async_scheduler_hook",
+ ext_functions,
+ PHP_MINIT(async_scheduler_hook),
+ PHP_MSHUTDOWN(async_scheduler_hook),
+ NULL,
+ PHP_RSHUTDOWN(async_scheduler_hook),
+ NULL,
+ PHP_ASYNC_SCHEDULER_HOOK_VERSION,
+ PHP_MODULE_GLOBALS(async_scheduler_hook),
+ PHP_GINIT(async_scheduler_hook),
+ NULL,
+ ZEND_MODULE_POST_ZEND_DEACTIVATE_N(async_scheduler_hook),
+ STANDARD_MODULE_PROPERTIES_EX,
+};
+
+#ifdef COMPILE_DL_ASYNC_SCHEDULER_HOOK
+# ifdef ZTS
+ZEND_TSRMLS_CACHE_DEFINE()
+# endif
+ZEND_GET_MODULE(async_scheduler_hook)
+#endif
diff --git a/ext/async_scheduler_hook/async_scheduler_hook.stub.php b/ext/async_scheduler_hook/async_scheduler_hook.stub.php
new file mode 100644
index 000000000000..8d35a5879d3d
--- /dev/null
+++ b/ext/async_scheduler_hook/async_scheduler_hook.stub.php
@@ -0,0 +1,147 @@
+. |
+ | |
+ | SPDX-License-Identifier: BSD-3-Clause |
+ +----------------------------------------------------------------------+
+ | Authors: Edmond |
+ +----------------------------------------------------------------------+
+*/
+#ifndef PHP_ASYNC_SCHEDULER_HOOK_H
+#define PHP_ASYNC_SCHEDULER_HOOK_H
+
+extern zend_module_entry async_scheduler_hook_module_entry;
+#define phpext_async_scheduler_hook_ptr &async_scheduler_hook_module_entry
+
+#define PHP_ASYNC_SCHEDULER_HOOK_VERSION "0.1.0"
+
+#if defined(ZTS) && defined(COMPILE_DL_ASYNC_SCHEDULER_HOOK)
+ZEND_TSRMLS_CACHE_EXTERN()
+#endif
+
+#endif /* PHP_ASYNC_SCHEDULER_HOOK_H */
diff --git a/ext/async_scheduler_hook/tests/context_basic.phpt b/ext/async_scheduler_hook/tests/context_basic.phpt
new file mode 100644
index 000000000000..e1730a84bd09
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/context_basic.phpt
@@ -0,0 +1,65 @@
+--TEST--
+Async\get_context(): the main coroutine's store; string and object keys
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+getMessage(), "\n";
+}
+
+register_mini_scheduler();
+
+$context = Async\get_context();
+var_dump($context instanceof Async\Context);
+
+// The same store on every call.
+var_dump(Async\get_context() === $context);
+
+// String keys: find/has/set/unset, set() chains.
+var_dump($context->has('request.id'));
+var_dump($context->set('request.id', 'r-1')->set('locale', 'en')->find('request.id'));
+var_dump($context->find('locale'));
+var_dump($context->has('request.id'));
+var_dump($context->unset('request.id'));
+var_dump($context->unset('request.id'));
+var_dump($context->find('request.id'));
+
+// A stored null is distinguishable from an absent key.
+$context->set('nullable', null);
+var_dump($context->find('nullable'), $context->has('nullable'));
+
+// Object keys.
+$key = new stdClass();
+$context->set($key, 'per-object');
+var_dump($context->find($key), $context->has($key));
+var_dump($context->has(new stdClass()));
+var_dump($context->unset($key));
+
+?>
+--EXPECT--
+Async\get_context(): no coroutine is running
+bool(true)
+bool(true)
+bool(false)
+string(3) "r-1"
+string(2) "en"
+bool(true)
+bool(true)
+bool(false)
+NULL
+NULL
+bool(true)
+string(10) "per-object"
+bool(true)
+bool(false)
+bool(true)
diff --git a/ext/async_scheduler_hook/tests/context_main_bridge.phpt b/ext/async_scheduler_hook/tests/context_main_bridge.phpt
new file mode 100644
index 000000000000..45b96995b836
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/context_main_bridge.phpt
@@ -0,0 +1,37 @@
+--TEST--
+Async\get_context(): the main coroutine carries the script's store from the launch on
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+set('who', 'main');
+
+$fiber = new Fiber(function () {
+ Async\get_context()->set('who', 'fiber');
+ var_dump(Async\get_context()->find('who'));
+});
+
+$fiber->start();
+
+var_dump(Async\get_context()->find('who'));
+
+// The same store through the scheduler's own main coroutine object.
+var_dump(Async\get_context($scheduler->main)->find('who'));
+var_dump(Async\get_context($scheduler->main) === Async\get_context());
+
+?>
+--EXPECT--
+string(5) "fiber"
+string(4) "main"
+string(4) "main"
+bool(true)
diff --git a/ext/async_scheduler_hook/tests/context_per_coroutine.phpt b/ext/async_scheduler_hook/tests/context_per_coroutine.phpt
new file mode 100644
index 000000000000..c1b2574ba5fb
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/context_per_coroutine.phpt
@@ -0,0 +1,48 @@
+--TEST--
+Async\get_context(): per-coroutine isolation and the explicit-object form
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+set('who', 'main');
+
+$make = static fn (string $name) => new Fiber(function () use ($name) {
+ Async\get_context()->set('who', $name);
+ Fiber::suspend();
+ var_dump(Async\get_context()->find('who'));
+});
+
+$fiberA = $make('A');
+$fiberB = $make('B');
+$fiberA->start();
+$fiberB->start();
+
+// Reaching the parked coroutines' contexts from outside, through the
+// scheduler's opaque coroutine objects (the explicit-object form).
+var_dump(Async\get_context($scheduler->adopted[0])->find('who'));
+var_dump(Async\get_context($scheduler->adopted[1])->find('who'));
+var_dump(Async\get_context()->find('who'));
+
+// Each coroutine still sees its own value when it resumes.
+$fiberA->resume();
+$fiberB->resume();
+var_dump(Async\get_context()->find('who'));
+
+?>
+--EXPECT--
+string(1) "A"
+string(1) "B"
+string(4) "main"
+string(1) "A"
+string(1) "B"
+string(4) "main"
diff --git a/ext/async_scheduler_hook/tests/context_unknown_object.phpt b/ext/async_scheduler_hook/tests/context_unknown_object.phpt
new file mode 100644
index 000000000000..d377caf7aa24
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/context_unknown_object.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Async\get_context(): an object the engine does not know as a coroutine is a ValueError
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Async\get_context(): Argument #1 ($coroutine) must be a coroutine object known to the scheduler
diff --git a/ext/async_scheduler_hook/tests/coroutine_switch.phpt b/ext/async_scheduler_hook/tests/coroutine_switch.phpt
new file mode 100644
index 000000000000..89db55e7e47d
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/coroutine_switch.phpt
@@ -0,0 +1,36 @@
+--TEST--
+switchTo capability: the body runs on the coroutine's own stack and returns to the switcher
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+coroutine(function (): void {
+ echo " A\n";
+});
+
+$b = $s->coroutine(function (): void {
+ $sum = 0;
+ for ($i = 0; $i < 1000; $i++) {
+ $sum += $i; // real VM work on the coroutine's own stack
+ }
+ echo " B sum=$sum\n";
+});
+
+echo "before\n";
+($s->switch)($a);
+($s->switch)($b);
+echo "after\n";
+?>
+--EXPECT--
+before
+ A
+ B sum=499500
+after
diff --git a/ext/async_scheduler_hook/tests/coroutine_switch_error.phpt b/ext/async_scheduler_hook/tests/coroutine_switch_error.phpt
new file mode 100644
index 000000000000..02d8b9edd3dd
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/coroutine_switch_error.phpt
@@ -0,0 +1,68 @@
+--TEST--
+switchTo capability: $error is delivered at the suspension point; boundary cases
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+coroutine(function () use ($s): void {
+ echo " worker started\n";
+ try {
+ ($s->switch)($s->main, "parked");
+ } catch (\RuntimeException $e) {
+ echo " caught at suspension point: {$e->getMessage()}\n";
+ }
+});
+
+var_dump(($s->switch)($worker));
+($s->switch)($worker, null, new \RuntimeException("cancelled"));
+
+// 2. Passing both a non-null value and an error is a ValueError.
+$other = $s->coroutine(function (): void {});
+try {
+ ($s->switch)($other, "value", new \RuntimeException("boom"));
+} catch (\ValueError $e) {
+ echo "ValueError\n";
+}
+
+// 3. A first entry with an error finishes the coroutine without starting
+// the body; the error surfaces at the switch site.
+$never = $s->coroutine(function (): void {
+ echo " never printed\n";
+});
+try {
+ ($s->switch)($never, null, new \LogicException("cancel before start"));
+} catch (\LogicException $e) {
+ echo "first entry: {$e->getMessage()}\n";
+}
+
+// 4. Switching into a finished coroutine throws an Error.
+try {
+ ($s->switch)($never);
+} catch (\Error $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// 5. A coroutine with no body cannot run.
+try {
+ ($s->switch)(new stdClass());
+} catch (\Error $e) {
+ echo $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+ worker started
+string(6) "parked"
+ caught at suspension point: cancelled
+ValueError
+first entry: cancel before start
+Cannot switch into a finished coroutine
+The object is not a coroutine the engine knows
diff --git a/ext/async_scheduler_hook/tests/coroutine_switch_main.phpt b/ext/async_scheduler_hook/tests/coroutine_switch_main.phpt
new file mode 100644
index 000000000000..9dfae4bd2211
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/coroutine_switch_main.phpt
@@ -0,0 +1,33 @@
+--TEST--
+switchTo capability: the main coroutine is switchable like any other
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+coroutine(function () use ($s): void {
+ echo " in coroutine\n";
+ $back = ($s->switch)($s->main, 'back to main'); // symmetric jump into main
+ echo " finishing ($back)\n";
+});
+
+var_dump(($s->switch)($c));
+echo "main resumed\n";
+($s->switch)($c, 'bye'); // let the body run to completion
+echo "done\n";
+?>
+--EXPECT--
+ in coroutine
+string(12) "back to main"
+main resumed
+ finishing (bye)
+done
diff --git a/ext/async_scheduler_hook/tests/coroutine_switch_nested.phpt b/ext/async_scheduler_hook/tests/coroutine_switch_nested.phpt
new file mode 100644
index 000000000000..3c5fe8641865
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/coroutine_switch_nested.phpt
@@ -0,0 +1,35 @@
+--TEST--
+switchTo capability: a coroutine switches into another mid-run; the inner returns to the outer (symmetric)
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+coroutine(function (): void {
+ echo " B: runs to completion\n";
+ // returns -> control goes back to B's switcher (A)
+});
+
+$a = $s->coroutine(function () use ($s, $b): void {
+ echo " A: 1\n";
+ ($s->switch)($b); // A -> B mid-run; B completes -> back to A
+ echo " A: 2 (after B)\n";
+});
+
+echo "main -> A\n";
+($s->switch)($a); // main -> A -> B(done) -> A(done) -> main
+echo "main: done\n";
+?>
+--EXPECT--
+main -> A
+ A: 1
+ B: runs to completion
+ A: 2 (after B)
+main: done
diff --git a/ext/async_scheduler_hook/tests/coroutine_switch_value_exception.phpt b/ext/async_scheduler_hook/tests/coroutine_switch_value_exception.phpt
new file mode 100644
index 000000000000..0a7b30e65ee4
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/coroutine_switch_value_exception.phpt
@@ -0,0 +1,32 @@
+--TEST--
+switchTo capability: returns the body's value and re-raises its exception
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+switch)($s->coroutine(fn () => 42)));
+var_dump(($s->switch)($s->coroutine(fn () => "hi")));
+
+try {
+ ($s->switch)($s->coroutine(function () {
+ throw new RuntimeException("boom");
+ }));
+} catch (RuntimeException $e) {
+ echo "caught: ", $e->getMessage(), "\n";
+}
+
+echo "survived\n";
+?>
+--EXPECT--
+int(42)
+string(2) "hi"
+caught: boom
+survived
diff --git a/ext/async_scheduler_hook/tests/fiber_lowlevel_null.phpt b/ext/async_scheduler_hook/tests/fiber_lowlevel_null.phpt
new file mode 100644
index 000000000000..fa6d47633d9d
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/fiber_lowlevel_null.phpt
@@ -0,0 +1,46 @@
+--TEST--
+onFiber returning null keeps the fiber on the low-level path
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+ new class implements \Async\Scheduler {
+ public function onLaunch(): object { return $this->main ??= new stdClass(); }
+ public ?object $main = null;
+ public function onShutdown(): void {}
+ public function onDefer(callable $task): void {}
+ public function onFiber(Fiber $fiber): ?object {
+ echo "intercept: null\n";
+ return null;
+ }
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ // Never called for the low-level fiber itself; the engine invokes it
+ // for the after-main handover (script end, then after destructors),
+ // and each handover replaces the finished main with a fresh one.
+ echo "suspend(fromMain: ", var_export($fromMain, true), ")\n";
+ return $fromMain ? ($this->main = new stdClass()) : null;
+ }
+});
+
+// A low-level fiber behaves exactly as classic Fiber even with a scheduler.
+$fiber = new Fiber(function (int $x): int {
+ $y = Fiber::suspend($x + 1);
+ return $y * 10;
+});
+
+var_dump($fiber->start(5));
+var_dump($fiber->resume(4));
+var_dump($fiber->getReturn());
+?>
+--EXPECT--
+intercept: null
+int(6)
+NULL
+int(40)
+suspend(fromMain: true)
+suspend(fromMain: true)
diff --git a/ext/async_scheduler_hook/tests/fiber_managed_after_main.phpt b/ext/async_scheduler_hook/tests/fiber_managed_after_main.phpt
new file mode 100644
index 000000000000..5e0a2ec6ab75
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/fiber_managed_after_main.phpt
@@ -0,0 +1,32 @@
+--TEST--
+After-main handover drains coroutines that became runnable at script end
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+start();
+
+// Nobody resumes the parked fiber during the script: hand its coroutine back
+// to the queue, so the after-main handover picks it up.
+$scheduler->onEnqueue($scheduler->lastAdopted);
+
+echo "end of script\n";
+?>
+--EXPECT--
+started
+end of script
+resumed by the after-main drain
diff --git a/ext/async_scheduler_hook/tests/fiber_managed_basic.phpt b/ext/async_scheduler_hook/tests/fiber_managed_basic.phpt
new file mode 100644
index 000000000000..57f0223f4618
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/fiber_managed_basic.phpt
@@ -0,0 +1,33 @@
+--TEST--
+A fiber adopted by the scheduler runs through the coroutine path
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+start(5));
+var_dump($fiber->isSuspended());
+var_dump($fiber->resume(4));
+var_dump($fiber->isTerminated());
+var_dump($fiber->getReturn());
+echo implode(',', $scheduler->log), "\n";
+?>
+--EXPECT--
+int(6)
+bool(true)
+NULL
+bool(true)
+int(40)
+intercept,enqueue,suspend,enqueue,suspend,enqueue,suspend,enqueue
diff --git a/ext/async_scheduler_hook/tests/fiber_managed_throw.phpt b/ext/async_scheduler_hook/tests/fiber_managed_throw.phpt
new file mode 100644
index 000000000000..9829091f7e02
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/fiber_managed_throw.phpt
@@ -0,0 +1,30 @@
+--TEST--
+Fiber::throw() on a managed fiber delivers the exception at the suspension point
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+getMessage();
+ }
+ return 'not reached';
+});
+
+var_dump($fiber->start());
+$fiber->throw(new RuntimeException('boom'));
+var_dump($fiber->getReturn());
+?>
+--EXPECT--
+string(7) "waiting"
+string(12) "caught: boom"
diff --git a/ext/async_scheduler_hook/tests/fiber_managed_uncaught.phpt b/ext/async_scheduler_hook/tests/fiber_managed_uncaught.phpt
new file mode 100644
index 000000000000..49deb77865e2
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/fiber_managed_uncaught.phpt
@@ -0,0 +1,29 @@
+--TEST--
+An uncaught exception in a managed fiber surfaces at the start()/resume() caller
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+start();
+} catch (RuntimeException $e) {
+ echo "caught: ", $e->getMessage(), "\n";
+}
+
+var_dump($fiber->isTerminated());
+?>
+--EXPECT--
+caught: escaped
+bool(true)
diff --git a/ext/async_scheduler_hook/tests/microtasks_basic.phpt b/ext/async_scheduler_hook/tests/microtasks_basic.phpt
new file mode 100644
index 000000000000..184537cb6f10
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/microtasks_basic.phpt
@@ -0,0 +1,55 @@
+--TEST--
+Microtasks: defer() forwards to the scheduler's onDefer() hook; the queue is the scheduler's
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+main ??= new stdClass(); }
+ public ?object $main = null;
+ public function onShutdown(): void {}
+ public function onFiber(\Fiber $fiber): ?object { return null; }
+ public SplQueue $tasks;
+ public function __construct() { $this->tasks = new SplQueue(); }
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ return $fromMain ? ($this->main = new stdClass()) : null;
+ }
+ public function onDefer(callable $task): void {
+ // The queue is owned by the scheduler, not by the engine.
+ $this->tasks->enqueue($task);
+ }
+};
+
+Async\SchedulerHook::register('test', fn () => $scheduler);
+
+Async\SchedulerHook::defer(function (): void {
+ echo "task 1\n";
+
+ // Queued while draining: the scheduler decides the semantics; this
+ // one drains everything queued before it finishes (classic microtasks).
+ Async\SchedulerHook::defer(function (): void {
+ echo "task 3 (queued by task 1)\n";
+ });
+});
+
+Async\SchedulerHook::defer(function (): void {
+ echo "task 2\n";
+});
+
+// The scheduler drains its own queue on its tick; simulate one here.
+while (!$scheduler->tasks->isEmpty()) {
+ ($scheduler->tasks->dequeue())();
+}
+
+echo "drained\n";
+?>
+--EXPECT--
+task 1
+task 2
+task 3 (queued by task 1)
+drained
diff --git a/ext/async_scheduler_hook/tests/mini_scheduler.inc b/ext/async_scheduler_hook/tests/mini_scheduler.inc
new file mode 100644
index 000000000000..e23539f1a02f
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/mini_scheduler.inc
@@ -0,0 +1,109 @@
+queue = new SplQueue();
+ }
+
+ public function onLaunch(): object
+ {
+ return $this->main ??= new stdClass();
+ }
+
+ public function onShutdown(): void {}
+
+ public function onDefer(callable $task): void {}
+
+ public function onFiber(Fiber $fiber): ?object
+ {
+ $this->log[] = 'intercept';
+ return $this->lastAdopted = $this->adopted[] = new stdClass();
+ }
+
+ // spawn() is the scheduler's own API, not part of the hook set.
+ public function spawn(Closure $entry): object
+ {
+ $coroutine = new stdClass();
+ ($this->bindEntry)($coroutine, $entry);
+ $this->onEnqueue($coroutine);
+ return $coroutine;
+ }
+
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool
+ {
+ $this->log[] = 'enqueue';
+ $this->queue->enqueue([$coroutine, $error]);
+ return true;
+ }
+
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object
+ {
+ $this->log[] = 'suspend';
+ $self = ($this->current)();
+
+ while (!$this->queue->isEmpty()) {
+ [$next, $error] = $this->queue->dequeue();
+
+ if ($next === $self) {
+ // The end-of-main handover: the finished main cannot run
+ // again, drop its stale wakes and keep draining.
+ if ($fromMain) {
+ continue;
+ }
+ // A self-resume: the flow yielded and its own turn came. A
+ // delivered error surfaces at its suspension point.
+ if ($error !== null) {
+ throw $error;
+ }
+ break;
+ }
+
+ $this->handoff = $next; // a deliberate wake: its turn came
+ ($this->switchTo)($next, null, $error);
+
+ if ($this->handoff === $self) {
+ break; // this flow was dequeued while it was
+ } // scheduling: stop and resume it
+ }
+
+ $this->handoff = null;
+
+ // The script's main coroutine really finished: whatever runs after
+ // the drain (shutdown functions, destructors) is a NEW main.
+ if ($fromMain) {
+ return $this->main = new stdClass();
+ }
+
+ return $self;
+ }
+}
+
+function register_mini_scheduler(): MiniScheduler
+{
+ $scheduler = null;
+ Async\SchedulerHook::register('mini',
+ function (Closure $bindEntry, Closure $switchTo, Closure $current) use (&$scheduler): MiniScheduler {
+ return $scheduler = new MiniScheduler($bindEntry, $switchTo, $current);
+ });
+
+ return $scheduler;
+}
diff --git a/ext/async_scheduler_hook/tests/scheduler_hook_constants.phpt b/ext/async_scheduler_hook/tests/scheduler_hook_constants.phpt
new file mode 100644
index 000000000000..4fafe8bcb799
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/scheduler_hook_constants.phpt
@@ -0,0 +1,20 @@
+--TEST--
+Async\Scheduler: interface shape
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+isInterface());
+
+$methods = array_map(fn ($m) => $m->name, $r->getMethods());
+sort($methods);
+echo implode(',', $methods), "\n";
+?>
+--EXPECT--
+bool(true)
+onDefer,onEnqueue,onFiber,onLaunch,onShutdown,onSuspend
diff --git a/ext/async_scheduler_hook/tests/scheduler_hook_invalid.phpt b/ext/async_scheduler_hook/tests/scheduler_hook_invalid.phpt
new file mode 100644
index 000000000000..803123e6bce5
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/scheduler_hook_invalid.phpt
@@ -0,0 +1,31 @@
+--TEST--
+Async\SchedulerHook::register validates the factory and what it returns
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+getMessage(), "\n";
+}
+
+// The factory must return an Async\Scheduler instance.
+try {
+ Async\SchedulerHook::register('test', fn () => new stdClass());
+} catch (\TypeError $e) {
+ echo $e->getMessage(), "\n";
+}
+
+// A refused registration leaves no active scheduler.
+var_dump(Async\SchedulerHook::getModule());
+?>
+--EXPECTF--
+Async\SchedulerHook::register(): Argument #2 ($factory) must be a valid callback, %s
+Async\SchedulerHook::register(): Argument #2 ($factory) must return an instance of Async\Scheduler
+NULL
diff --git a/ext/async_scheduler_hook/tests/scheduler_hook_launch.phpt b/ext/async_scheduler_hook/tests/scheduler_hook_launch.phpt
new file mode 100644
index 000000000000..7cc11b1d5d6f
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/scheduler_hook_launch.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Async\SchedulerHook: the factory runs synchronously inside register()
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+main ??= new stdClass(); }
+ public ?object $main = null;
+ public function onShutdown(): void {}
+ public function onFiber(\Fiber $fiber): ?object { return null; }
+ public function onDefer(callable $task): void {}
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ return $fromMain ? ($this->main = new stdClass()) : null;
+ }
+ };
+};
+
+$order[] = 'before register';
+Async\SchedulerHook::register('test', $factory);
+$order[] = 'after register';
+
+// The engine launch point has already passed by the time userland runs, so
+// the factory (the launch moment for a PHP scheduler) runs synchronously
+// inside register().
+echo implode("\n", $order), "\n";
+?>
+--EXPECT--
+before register
+factory
+after register
diff --git a/ext/async_scheduler_hook/tests/scheduler_hook_override.phpt b/ext/async_scheduler_hook/tests/scheduler_hook_override.phpt
new file mode 100644
index 000000000000..e84b69f93968
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/scheduler_hook_override.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Async\SchedulerHook::register can be called only once
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+ new class implements \Async\Scheduler {
+ public function onLaunch(): object { return $this->main ??= new stdClass(); }
+ public ?object $main = null;
+ public function onShutdown(): void {}
+ public function onFiber(\Fiber $fiber): ?object { return null; }
+ public function onDefer(callable $task): void {}
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ return $fromMain ? ($this->main = new stdClass()) : null;
+ }
+};
+
+// First registration succeeds.
+Async\SchedulerHook::register('a', $make);
+echo "registered\n";
+
+// A second registration throws: a scheduler is registered once per process.
+try {
+ Async\SchedulerHook::register('b', $make);
+} catch (\Error $e) {
+ echo $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+registered
+A scheduler is already registered
diff --git a/ext/async_scheduler_hook/tests/scheduler_hook_register.phpt b/ext/async_scheduler_hook/tests/scheduler_hook_register.phpt
new file mode 100644
index 000000000000..f92a844ebbe7
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/scheduler_hook_register.phpt
@@ -0,0 +1,29 @@
+--TEST--
+Async\SchedulerHook::register activates and getModule() reports the driver
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+ new class implements \Async\Scheduler {
+ public function onLaunch(): object { return $this->main ??= new stdClass(); }
+ public ?object $main = null;
+ public function onShutdown(): void {}
+ public function onFiber(\Fiber $fiber): ?object { return null; }
+ public function onDefer(callable $task): void {}
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ return $fromMain ? ($this->main = new stdClass()) : null;
+ }
+});
+
+var_dump(Async\SchedulerHook::getModule());
+?>
+--EXPECT--
+NULL
+string(9) "my-driver"
diff --git a/ext/async_scheduler_hook/tests/scheduler_main_replacement.phpt b/ext/async_scheduler_hook/tests/scheduler_main_replacement.phpt
new file mode 100644
index 000000000000..51214c16b689
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/scheduler_main_replacement.phpt
@@ -0,0 +1,60 @@
+--TEST--
+End-of-main handover: the finished main is replaced with a fresh main coroutine
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+main = $this->mains[] = new stdClass();
+ }
+
+ public function onShutdown(): void {}
+ public function onFiber(Fiber $fiber): ?object { return null; }
+ public function onDefer(callable $task): void {}
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ if (!$fromMain) {
+ return null;
+ }
+
+ // index.php's main really finished; the flow that runs from here
+ // (shutdown functions, destructors) is a different main coroutine.
+ $this->main = $this->mains[] = new stdClass();
+ echo "handover: main #", count($this->mains), " takes over\n";
+ return $this->main;
+ }
+}
+
+$sched = null;
+Async\SchedulerHook::register('test',
+ function (Closure $bind, Closure $switch, Closure $current) use (&$sched): ReplacingScheduler {
+ return $sched = new ReplacingScheduler($current);
+ });
+
+$scriptMain = ($sched->current)();
+var_dump($scriptMain === $sched->mains[0]);
+
+register_shutdown_function(function () use ($sched, $scriptMain) {
+ $now = ($sched->current)();
+ echo "shutdown function runs in a new main: ";
+ var_dump($now !== $scriptMain && $now === $sched->mains[1]);
+});
+
+echo "end of script\n";
+?>
+--EXPECT--
+bool(true)
+end of script
+handover: main #2 takes over
+shutdown function runs in a new main: bool(true)
+handover: main #3 takes over
diff --git a/ext/async_scheduler_hook/tests/scheduler_mandate.phpt b/ext/async_scheduler_hook/tests/scheduler_mandate.phpt
new file mode 100644
index 000000000000..43c02fce44d2
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/scheduler_mandate.phpt
@@ -0,0 +1,53 @@
+--TEST--
+The register() factory receives the bindEntry/switchTo/currentCoroutine mandate — and nobody else does
+--EXTENSIONS--
+async_scheduler_hook
+--SKIPIF--
+
+--FILE--
+main ??= new stdClass(); }
+ public ?object $main = null;
+ public function __construct(
+ public readonly Closure $bind, // bindEntry(object $coroutine, callable $entry): void
+ public readonly Closure $switch, // switchTo(object $coroutine, mixed $value = null, ?Throwable $error = null): mixed
+ public readonly Closure $current, // currentCoroutine(): ?object
+ ) {}
+
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ return $fromMain ? ($this->main = new stdClass()) : null;
+ }
+ public function onShutdown(): void {}
+ public function onFiber(Fiber $fiber): ?object { return null; }
+ public function onDefer(callable $task): void {}
+}
+
+$sched = null;
+Async\SchedulerHook::register('test',
+ function (Closure $bind, Closure $switch, Closure $current) use (&$sched): MandateScheduler {
+ // The scheduler is created in a valid state: the mandate arrives
+ // through the constructor, not through a later hook.
+ return $sched = new MandateScheduler($bind, $switch, $current);
+ });
+
+// bindEntry gives one of the scheduler's coroutines a body; switchTo drives it.
+$c = new stdClass();
+($sched->bind)($c, function (): void { echo " in coroutine\n"; });
+($sched->switch)($c);
+
+// currentCoroutine reports the coroutine the engine records: the main one,
+// minted by onLaunch() before the script ran.
+var_dump(($sched->current)() === $sched->main);
+
+// The capabilities are closures over engine internals: no public class or
+// function exposes them.
+var_dump(class_exists('Async\\Continuation'));
+?>
+--EXPECT--
+ in coroutine
+bool(true)
+bool(false)
diff --git a/ext/async_scheduler_hook/tests/switch_harness.inc b/ext/async_scheduler_hook/tests/switch_harness.inc
new file mode 100644
index 000000000000..600d324cb53d
--- /dev/null
+++ b/ext/async_scheduler_hook/tests/switch_harness.inc
@@ -0,0 +1,50 @@
+main ??= new stdClass();
+ }
+
+ public function onShutdown(): void {}
+ public function onFiber(Fiber $fiber): ?object { return null; }
+ public function onDefer(callable $task): void {}
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object
+ {
+ return $fromMain ? ($this->main = new stdClass()) : null;
+ }
+
+ /** A fresh coroutine with $entry as its body. */
+ public function coroutine(Closure $entry): object
+ {
+ $coroutine = new stdClass();
+ ($this->bind)($coroutine, $entry);
+ return $coroutine;
+ }
+}
+
+function register_switch_harness(): SwitchHarness
+{
+ $scheduler = null;
+ Async\SchedulerHook::register('switch-harness',
+ function (Closure $bind, Closure $switch, Closure $current) use (&$scheduler): SwitchHarness {
+ return $scheduler = new SwitchHarness($bind, $switch, $current);
+ });
+
+ return $scheduler;
+}
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..49293237dfdf
--- /dev/null
+++ b/ext/test_scheduler/test_scheduler.c
@@ -0,0 +1,1963 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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 (the PHP
+ * SchedulerHook bridge). 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. Verified empirically: the walk produced
+ * zero observable difference. */
+ 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)
+{
+ 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..3817205dc704
--- /dev/null
+++ b/ext/test_scheduler/tests/026_dtor_fiber_suspend.phpt
@@ -0,0 +1,61 @@
+--TEST--
+Fibers in destructors 001: Suspend in destructor — under test_scheduler
+--SKIPIF--
+
+--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) {
+ global $f2;
+ $f2 = Fiber::getCurrent();
+ Fiber::suspend(new stdClass);
+ }
+ printf("%d: End destruct\n", $id);
+ }
+}
+
+$f = new Fiber(function () {
+ global $f2;
+ new Cycle();
+ new Cycle();
+ new Cycle();
+ new Cycle();
+ new Cycle();
+ gc_collect_cycles();
+ $f2->resume();
+});
+
+$f->start();
+
+?>
+--EXPECT--
+0: Start destruct
+1: Start destruct
+1: End destruct
+2: Start destruct
+2: End destruct
+3: Start destruct
+3: End destruct
+4: Start destruct
+4: End destruct
+0: End destruct
+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/028_dtor_fiber_resume.phpt b/ext/test_scheduler/tests/028_dtor_fiber_resume.phpt
new file mode 100644
index 000000000000..91273c1d8073
--- /dev/null
+++ b/ext/test_scheduler/tests/028_dtor_fiber_resume.phpt
@@ -0,0 +1,50 @@
+--TEST--
+Fibers in destructors 003: Resume in destructor — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+ public function __destruct() {
+ $id = self::$counter++;
+ printf("%d: Start destruct\n", $id);
+ global $f;
+ $f->resume();
+ printf("%d: End destruct\n", $id);
+ }
+}
+
+$f = new Fiber(function () {
+ while (true) {
+ Fiber::suspend();
+ }
+});
+$f->start();
+
+new Cycle();
+new Cycle();
+gc_collect_cycles();
+
+?>
+--EXPECT--
+0: Start destruct
+0: End destruct
+1: Start destruct
+1: End destruct
+Shutdown
diff --git a/ext/test_scheduler/tests/029_dtor_fiber_suspend_throw.phpt b/ext/test_scheduler/tests/029_dtor_fiber_suspend_throw.phpt
new file mode 100644
index 000000000000..3b64d2479641
--- /dev/null
+++ b/ext/test_scheduler/tests/029_dtor_fiber_suspend_throw.phpt
@@ -0,0 +1,87 @@
+--TEST--
+Fibers in destructors 004: Suspend and throw in destructor — under test_scheduler
+--SKIPIF--
+
+--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) {
+ global $f2;
+ $f2 = Fiber::getCurrent();
+ Fiber::suspend(new stdClass);
+ }
+ printf("%d: End destruct\n", $id);
+ throw new \Exception(sprintf("%d exception", $id));
+ }
+}
+
+$f = new Fiber(function () {
+ global $f2;
+ new Cycle();
+ new Cycle();
+ new Cycle();
+ try {
+ gc_collect_cycles();
+ } catch (\Exception $e) {
+ echo $e, "\n";
+ }
+ $f2->resume();
+});
+
+$f->start();
+
+?>
+--EXPECTF--
+0: Start destruct
+1: Start destruct
+1: End destruct
+2: Start destruct
+2: End destruct
+Exception: 1 exception in %s:%d
+Stack trace:
+#0 [internal function]: Cycle->__destruct()
+#1 [internal function]: gc_destructor_fiber()
+#2 %s(%d): gc_collect_cycles()
+#3 [internal function]: {closure:%s:%d}()
+#4 %s(%d): Fiber->start()
+#5 {main}
+
+Next Exception: 2 exception in %s:%d
+Stack trace:
+#0 [internal function]: Cycle->__destruct()
+#1 [internal function]: gc_destructor_fiber()
+#2 %s(%d): gc_collect_cycles()
+#3 [internal function]: {closure:%s:%d}()
+#4 %s(%d): Fiber->start()
+#5 {main}
+0: End destruct
+
+Fatal error: Uncaught Exception: 0 exception in %s:%d
+Stack trace:
+#0 [internal function]: Cycle->__destruct()
+#1 [internal function]: gc_destructor_fiber()
+#2 %s(%d): Fiber->resume()
+#3 [internal function]: {closure:%s:%d}()
+#4 %s(%d): Fiber->start()
+#5 {main}
+ thrown in %s on line %d
+Shutdown
diff --git a/ext/test_scheduler/tests/030_dtor_fiber_abandoned.phpt b/ext/test_scheduler/tests/030_dtor_fiber_abandoned.phpt
new file mode 100644
index 000000000000..86bb345a8385
--- /dev/null
+++ b/ext/test_scheduler/tests/030_dtor_fiber_abandoned.phpt
@@ -0,0 +1,68 @@
+--TEST--
+Fibers in destructors 005: Suspended and not resumed destructor — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+ public function __destruct() {
+ printf("%d: Start destruct\n", $this->id);
+ try {
+ if ($this->id === 0) {
+ /* Fiber will be collected by GC because it's not referenced */
+ Fiber::suspend(new stdClass);
+ } else if ($this->id === 1) {
+ /* Fiber will be dtor during shutdown */
+ global $f2;
+ $f2 = Fiber::getCurrent();
+ Fiber::suspend(new stdClass);
+ }
+ } finally {
+ printf("%d: End destruct\n", $this->id);
+ }
+ }
+}
+
+$refs = [];
+$f = new Fiber(function () use (&$refs) {
+ $refs[] = WeakReference::create(new Cycle(0));
+ $refs[] = WeakReference::create(new Cycle(1));
+ $refs[] = WeakReference::create(new Cycle(2));
+ gc_collect_cycles();
+});
+
+$f->start();
+
+gc_collect_cycles();
+
+foreach ($refs as $id => $ref) {
+ printf("%d: %s\n", $id, $ref->get() ? 'Live' : 'Collected');
+}
+
+?>
+--EXPECT--
+2: Start destruct
+2: End destruct
+0: Start destruct
+0: End destruct
+1: Start destruct
+0: Collected
+1: Live
+2: Collected
+Shutdown
+1: End destruct
diff --git a/ext/test_scheduler/tests/031_dtor_fiber_multiple_gc_runs.phpt b/ext/test_scheduler/tests/031_dtor_fiber_multiple_gc_runs.phpt
new file mode 100644
index 000000000000..f5d56d6610b4
--- /dev/null
+++ b/ext/test_scheduler/tests/031_dtor_fiber_multiple_gc_runs.phpt
@@ -0,0 +1,60 @@
+--TEST--
+Fibers in destructors 006: multiple GC runs — under test_scheduler
+--SKIPIF--
+
+--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) {
+ global $f2;
+ $f2 = Fiber::getCurrent();
+ Fiber::suspend(new stdClass);
+ }
+ printf("%d: End destruct\n", $id);
+ }
+}
+
+$f = new Fiber(function () {
+ new Cycle();
+ new Cycle();
+ gc_collect_cycles();
+});
+
+$f->start();
+
+new Cycle();
+new Cycle();
+gc_collect_cycles();
+
+$f2->resume();
+
+?>
+--EXPECT--
+0: Start destruct
+1: Start destruct
+1: End destruct
+2: Start destruct
+2: End destruct
+3: Start destruct
+3: End destruct
+0: End destruct
+Shutdown
diff --git a/ext/test_scheduler/tests/032_dtor_fiber_resurrect.phpt b/ext/test_scheduler/tests/032_dtor_fiber_resurrect.phpt
new file mode 100644
index 000000000000..1c54ee773185
--- /dev/null
+++ b/ext/test_scheduler/tests/032_dtor_fiber_resurrect.phpt
@@ -0,0 +1,47 @@
+--TEST--
+Fibers in destructors 009: Destructor resurrects object, suspends — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+ public function __destruct() {
+ global $ref, $f2;
+ $ref = $this;
+ $f2 = Fiber::getCurrent();
+ Fiber::suspend();
+ }
+}
+
+$f = new Fiber(function () {
+ global $weakRef;
+ $weakRef = WeakReference::create(new Cycle());
+ gc_collect_cycles();
+});
+
+$f->start();
+var_dump((bool) $weakRef->get());
+gc_collect_cycles();
+$f2->resume();
+gc_collect_cycles();
+var_dump((bool) $weakRef->get());
+?>
+--EXPECT--
+bool(true)
+bool(true)
+Shutdown
diff --git a/ext/test_scheduler/tests/033_dtor_fiber_resurrect_unref.phpt b/ext/test_scheduler/tests/033_dtor_fiber_resurrect_unref.phpt
new file mode 100644
index 000000000000..8b34746d5cad
--- /dev/null
+++ b/ext/test_scheduler/tests/033_dtor_fiber_resurrect_unref.phpt
@@ -0,0 +1,48 @@
+--TEST--
+Fibers in destructors 010: Destructor resurrects object, suspends, unrefs — under test_scheduler
+--SKIPIF--
+
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+ public function __destruct() {
+ global $ref, $f2;
+ $ref = $this;
+ $f2 = Fiber::getCurrent();
+ Fiber::suspend();
+ $ref = null;
+ }
+}
+
+$f = new Fiber(function () {
+ global $weakRef;
+ $weakRef = WeakReference::create(new Cycle());
+ gc_collect_cycles();
+});
+
+$f->start();
+var_dump((bool) $weakRef->get());
+gc_collect_cycles();
+$f2->resume();
+gc_collect_cycles();
+var_dump((bool) $weakRef->get());
+?>
+--EXPECT--
+bool(true)
+bool(false)
+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..208751c89e36
--- /dev/null
+++ b/ext/test_scheduler/tests/035_gc_fiber_generator_dtors.phpt
@@ -0,0 +1,36 @@
+--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..8ddcbcfbb719
--- /dev/null
+++ b/ext/test_scheduler/tests/037_gc_dtor_iterator_loop.phpt
@@ -0,0 +1,41 @@
+--TEST--
+OSS-Fuzz #471533782: Infinite loop in GC destructor fiber — under test_scheduler
+--SKIPIF--
+
+--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 Exception in %s:%d
+Stack trace:
+#0 [internal function]: Cycle->__destruct()
+#1 [internal function]: gc_destructor_fiber()
+#2 {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..4e97a3d80424
--- /dev/null
+++ b/ext/test_scheduler/tests/038_gc_dtor_iterator_loop_2.phpt
@@ -0,0 +1,42 @@
+--TEST--
+OSS-Fuzz #471533782: Infinite loop in GC destructor fiber — under test_scheduler
+--SKIPIF--
+
+--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 in a force-closed fiber in %s:%d
+Stack trace:
+#0 %s(%d): Fiber::suspend()
+#1 [internal function]: Cycle->__destruct()
+#2 [internal function]: gc_destructor_fiber()
+#3 {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..cb3d964b9288
--- /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', '131072');
+
+var_dump(TestScheduler\await(TestScheduler\spawn(fn () => "ok")));
+
+echo "==DONE==\n";
+?>
+--EXPECT--
+Exception: Fiber stack size is too small, it needs to be at least 8192 bytes
+string(2) "ok"
+==DONE==
diff --git a/main/main.c b/main/main.c
index 9e77c99a45df..7d9fcd62835b 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"
/* }}} */
@@ -1992,6 +1994,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();
@@ -2239,6 +2256,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
@@ -2562,6 +2580,8 @@ void php_module_shutdown(void)
module_initialized = false;
+ zend_async_api_shutdown();
+
#ifndef ZTS
core_globals_dtor(&core_globals);
gc_globals_dtor();
@@ -2648,6 +2668,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;
}
@@ -2657,7 +2679,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();
@@ -2831,7 +2856,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 a0f26033306f..7828acd799c5 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_lazy_objects.c zend_autoload.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({