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