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