diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a25ed1..e31b37c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **#161 `spawn_thread`/`ThreadPool`/`ThreadChannel`: transferred closures lost their class scope** — a `static` closure declared inside a class arrived in the worker unscoped, so its first `self::`/`static::` threw `Cannot access "self" when no class scope is active`; a `$this`-bound closure got `Z_OBJCE($this)` instead of its declaring class, breaking `self::` and private-member visibility under inheritance. The snapshot now carries `scope`/`called_scope` by name and re-resolves them in the target thread; a missing class throws `Cannot restore closure scope: class "X" not found in the target thread` instead of silently dropping the scope. Closures scoped to anonymous classes are rejected at transfer time. Tests `tests/thread/072`–`073`; `057` re-pinned to native scope semantics. - **`ThreadPool` synchronous task: snapshot use-after-free when the task spawns an un-awaited coroutine** — a sync-mode task body ran inline in the worker and its per-task snapshot arena (which backs every spawned closure's op_array) was freed the instant the body returned, while a coroutine the body had spawned was still pending; running it later dereferenced freed memory (Windows debug-heap crash; ASAN-caught on Linux). The body now runs as a coroutine in its own per-task **nursery** `Scope`: `Async\spawn()` inside the body lands in that scope on its own (no scope-pointer hijacking), and on task exit the scope is cancelled and *drained* — awaited until every spawned coroutine is physically disposed — before the snapshot is freed. ABI bumped to v0.20.0: new `zend_async_scope_await_after_cancellation_fn` exposes the C core of `Scope::awaitAfterCancellation` so the worker reuses the canonical zombie-aware drain instead of hand-rolling it. Regression test `tests/thread_pool/065-task_scope_nursery_no_uaf.phpt`. - **`ThreadPool`: fatal in a task no longer leaves a use-after-free or a leaked libuv loop** — a fatal (e.g. OOM) in a task body now rejects the future with `ThreadTransferException` and tears the pool down cleanly. The snapshot's op_array name strings (`function_name`, `filename`) are materialized into refcounted heap strings so holders that outlive the snapshot arena (the closure, `PG(last_error_file)`) are freed by refcount instead of dangling. The `zend_bailout()` that a fatal re-raises through a parked `ThreadChannel` send/recv or the worker's slot wait is now caught so the channel/slot trigger is disposed before re-raising — an undisposed trigger's open `uv_async` would block `uv_loop_close` and leak the reactor's loop internals. Debug builds dump any libuv handle that survives reactor shutdown. Regression tests `tests/thread_pool/066`–`068`. - **`ThreadPool` coroutine-mode task: a fatal now reports its cause instead of resolving to `null`** — in `coroutine: true` mode a task that hit a fatal/OOM (or `exit()`/`die()`) resolved its future to a silent `null`, because the completion path only checked for a thrown exception and a bailout is not one. It now detects the bailout (no exception, `UNDEF` result) and rejects the future with a `ThreadTransferException` carrying the fatal message, matching synchronous mode. Tests `tests/thread_pool/067`, `068`. diff --git a/fuzzy-tests/_harness/Steps.php b/fuzzy-tests/_harness/Steps.php index 1289a5d..da6925a 100644 --- a/fuzzy-tests/_harness/Steps.php +++ b/fuzzy-tests/_harness/Steps.php @@ -22,6 +22,15 @@ require_once __DIR__ . '/StepRegistry.php'; final class StandardSteps { + /** + * Worker-thread payloads below are lexically declared inside this class, + * but chaos workers boot bare (no harness classes). Strip the class scope + * so the transfer does not require StandardSteps on the worker side. + */ + private static function unscoped(\Closure $fn): \Closure { + return \Closure::bind($fn, null, null); + } + public static function register(StepRegistry $r): StepRegistry { // ---- Given: setup ---- @@ -3660,7 +3669,7 @@ function(Context $ctx, string $coro, string $nExpr, string $pool) { for ($i = 0; $i < $n; $i++) { $ctx->inc("tp_submit_attempts_$pool"); try { - $f = $p->submit(static fn(int $idx): int => $idx, $i); + $f = $p->submit(self::unscoped(static fn(int $idx): int => $idx), $i); $ctx->threadPoolFutures[$pool][] = $f; $ctx->inc("tp_submitted_$pool"); } catch (\Throwable $e) { @@ -3760,7 +3769,7 @@ function(Context $ctx, string $coro, string $nExpr, string $pool) { $items = range(0, $n - 1); $ctx->inc("tp_map_attempts_$pool"); try { - $res = $ctx->threadPools[$pool]->map($items, static fn(int $i): int => $i * $i); + $res = $ctx->threadPools[$pool]->map($items, self::unscoped(static fn(int $i): int => $i * $i)); $ctx->inc("tp_map_succeeded_$pool"); $ctx->inc("tp_map_results_$pool", count($res)); } catch (\Throwable $e) { @@ -3831,11 +3840,11 @@ function(Context $ctx, string $coro, string $nExpr) { for ($i = 0; $i < $n; $i++) { $ctx->inc("thr_spawn_attempts_$coro"); try { - $h = \Async\spawn_thread(static function() use ($i): array { + $h = \Async\spawn_thread(self::unscoped(static function() use ($i): array { $x = 0.0; for ($j = 0; $j < 20000; $j++) { $x += sqrt($j); } return ['idx' => $i, 'x' => $x]; - }); + })); $ctx->threadHandles[$coro][] = $h; $ctx->inc("thr_spawned_$coro"); } catch (\Throwable $e) { @@ -3854,11 +3863,11 @@ function(Context $ctx, string $coro, string $nExpr) { for ($i = 0; $i < $n; $i++) { $ctx->inc("thr_spawn_attempts_$coro"); try { - $h = \Async\spawn_thread(static function() use ($i): void { + $h = \Async\spawn_thread(self::unscoped(static function() use ($i): void { $x = 0.0; for ($j = 0; $j < 20000; $j++) { $x += sqrt($j); } throw new \RuntimeException('thread boom ' . $i); - }); + })); $ctx->threadHandles[$coro][] = $h; $ctx->inc("thr_spawned_$coro"); } catch (\Throwable $e) { @@ -3880,11 +3889,11 @@ function(Context $ctx, string $coro, string $nExpr) { for ($i = 0; $i < $n; $i++) { $ctx->inc("thr_spawn_attempts_$coro"); try { - \Async\spawn_thread(static function() use ($i): array { + \Async\spawn_thread(self::unscoped(static function() use ($i): array { $x = 0.0; for ($j = 0; $j < 40000; $j++) { $x += sqrt($j); } return ['idx' => $i, 'x' => $x, 'buf' => str_repeat('w', 64)]; - }); + })); $ctx->inc("thr_spawned_$coro"); } catch (\Throwable $e) { $ctx->inc("thr_spawn_failed_$coro"); @@ -3981,9 +3990,9 @@ function(Context $ctx, string $coro, string $f, string $valExpr) { $state = $ctx->futureStates[$f]; $ctx->inc("rf_xfer_attempts_$f"); try { - $h = \Async\spawn_thread(static function() use ($state, $val) { + $h = \Async\spawn_thread(self::unscoped(static function() use ($state, $val) { $state->complete($val); - }); + })); $ctx->remoteFutureThreads[$f] = $h; $ctx->inc("rf_xfer_ok_$f"); } catch (\Throwable $e) { @@ -4004,9 +4013,9 @@ function(Context $ctx, string $coro, string $f, string $msg) { $state = $ctx->futureStates[$f]; $ctx->inc("rf_xfer_attempts_$f"); try { - $h = \Async\spawn_thread(static function() use ($state, $msg) { + $h = \Async\spawn_thread(self::unscoped(static function() use ($state, $msg) { $state->error(new \RuntimeException($msg)); - }); + })); $ctx->remoteFutureThreads[$f] = $h; $ctx->inc("rf_xfer_ok_$f"); } catch (\Throwable $e) { @@ -4031,9 +4040,9 @@ function(Context $ctx, string $coro, string $f) { $state = $ctx->futureStates[$f]; $ctx->inc("rf_xfer_attempts_$f"); try { - $h = \Async\spawn_thread(static function() use ($state) { + $h = \Async\spawn_thread(self::unscoped(static function() use ($state) { throw new \RuntimeException("worker crashed before complete"); - }); + })); $ctx->remoteFutureThreads[$f] = $h; $ctx->inc("rf_xfer_ok_$f"); } catch (\Throwable $e) { @@ -4108,10 +4117,10 @@ function(Context $ctx, string $coro, string $f) { $state = $ctx->futureStates[$f]; $ctx->inc("rf_double_xfer_attempts_$f"); try { - $h2 = \Async\spawn_thread(static function() use ($state) { + $h2 = \Async\spawn_thread(self::unscoped(static function() use ($state) { // unreachable if transfer is blocked try { $state->complete("dup"); } catch (\Throwable $e) {} - }); + })); // Transfer was allowed — join the rogue so we don't leak it. try { \Async\await($h2); } catch (\Throwable $e) {} $ctx->inc("rf_double_xfer_allowed_$f"); @@ -4180,12 +4189,12 @@ function(Context $ctx, string $coro, string $nExpr, string $tc) { } $ch = $ctx->threadChannels[$tc]; $ctx->inc("tc_thread_send_attempts_$tc"); - $h = \Async\spawn_thread(static function() use ($ch, $n): int { + $h = \Async\spawn_thread(self::unscoped(static function() use ($ch, $n): int { for ($i = 0; $i < $n; $i++) { $ch->send($i); } return $n; - }); + })); for ($i = 0; $i < $n; $i++) { try { $ch->recv(); @@ -4218,12 +4227,12 @@ function(Context $ctx, string $coro, string $nExpr, string $tc) { } $ch = $ctx->threadChannels[$tc]; $ctx->inc("tc_thread_recv_attempts_$tc"); - $h = \Async\spawn_thread(static function() use ($ch, $n): int { + $h = \Async\spawn_thread(self::unscoped(static function() use ($ch, $n): int { for ($i = 0; $i < $n; $i++) { $ch->recv(); } return $n; - }); + })); for ($i = 0; $i < $n; $i++) { try { $ch->send($i); @@ -4256,14 +4265,14 @@ function(Context $ctx, string $coro, string $tc) { } $ch = $ctx->threadChannels[$tc]; $ctx->inc("tc_close_race_attempts_$tc"); - $h = \Async\spawn_thread(static function() use ($ch): string { + $h = \Async\spawn_thread(self::unscoped(static function() use ($ch): string { try { $ch->recv(); return "no-throw"; } catch (\Throwable $e) { return "threw"; } - }); + })); $ch->close(); try { $outcome = \Async\await($h); diff --git a/tests/thread/057-spawn_thread_this_subclass_self.phpt b/tests/thread/057-spawn_thread_this_subclass_self.phpt index 35753a7..96fd10d 100644 --- a/tests/thread/057-spawn_thread_this_subclass_self.phpt +++ b/tests/thread/057-spawn_thread_this_subclass_self.phpt @@ -6,12 +6,9 @@ if (!PHP_ZTS) die('skip ZTS required'); if (!function_exists('Async\spawn_thread')) die('skip spawn_thread not available'); ?> --DESCRIPTION-- -Documents the current scope semantics for transferred closures. In native PHP `self::X` resolves to the closure's *defining* class (Base). -Across spawn_thread the worker scope is currently set to Z_OBJCE($this), -so `self::X` resolves to Child::X. Same closure called locally still -gives Base::X — this test pins both behaviors so a future fix is -visible as an EXPECT diff. +The transferred closure carries its scope by name, so the worker matches +the local result: self::X is Base::X on both sides. --FILE-- --EXPECT-- local: base -worker: child +worker: base diff --git a/tests/thread/072-spawn_thread_static_closure_self.phpt b/tests/thread/072-spawn_thread_static_closure_self.phpt new file mode 100644 index 0000000..49b8e3c --- /dev/null +++ b/tests/thread/072-spawn_thread_static_closure_self.phpt @@ -0,0 +1,35 @@ +--TEST-- +spawn_thread() - static closure declared in a class keeps self::/static:: scope +--SKIPIF-- + +--FILE-- + +--EXPECT-- +reached|Demo diff --git a/tests/thread/073-spawn_thread_closure_scope_class_missing.phpt b/tests/thread/073-spawn_thread_closure_scope_class_missing.phpt new file mode 100644 index 0000000..1b5dd6c --- /dev/null +++ b/tests/thread/073-spawn_thread_closure_scope_class_missing.phpt @@ -0,0 +1,32 @@ +--TEST-- +spawn_thread() - class-scoped closure with no bootloader → clear scope error +--SKIPIF-- + +--FILE-- +getMessage(), "\n"; + } +}); +?> +--EXPECTF-- +%ACannot restore closure scope: class "Demo" not found%A diff --git a/thread.c b/thread.c index e5dd913..63b1d55 100644 --- a/thread.c +++ b/thread.c @@ -1552,7 +1552,8 @@ static bool async_thread_check_op_array(zend_op_array *op_array) * captured variables via async_thread_transfer_zval. */ static void thread_copy_callable( - thread_copy_ctx_t *ctx, const zend_fcall_t *fcall, async_thread_closure_copy_t *dst) + thread_copy_ctx_t *ctx, const zend_fcall_t *fcall, async_thread_closure_copy_t *dst, + bool strip_scope) { zend_op_array *src_op = &fcall->fci_cache.function_handler->op_array; @@ -1563,6 +1564,19 @@ static void thread_copy_callable( return; } + /* CE pointers are thread-local: carry scope/called_scope by name and + * re-resolve them in the target thread. Anonymous classes have no + * resolvable name there. Bootloaders are copied with strip_scope: they + * bootstrap a thread where their own declaring class may not exist yet. */ + const zend_class_entry *scope = strip_scope ? NULL : src_op->scope; + const zend_class_entry *called_scope = strip_scope ? NULL : fcall->fci_cache.called_scope; + + if ((scope != NULL && (scope->ce_flags & ZEND_ACC_ANON_CLASS) != 0) + || (called_scope != NULL && (called_scope->ce_flags & ZEND_ACC_ANON_CLASS) != 0)) { + zend_throw_error(NULL, "Cannot transfer a closure scoped to an anonymous class between threads"); + return; + } + /* Deep copy the op_array into arena */ zval tmp; ZVAL_PTR(&tmp, (void *) src_op); @@ -1571,6 +1585,18 @@ static void thread_copy_callable( ZVAL_UNDEF(&dst->bound_this); dst->bound_vars = NULL; + dst->scope_name = NULL; + dst->called_scope_name = NULL; + + if (scope != NULL) { + dst->scope_name = zend_string_init(ZSTR_VAL(scope->name), ZSTR_LEN(scope->name), 1); + GC_MAKE_PERSISTENT_LOCAL(dst->scope_name); + } + + if (called_scope != NULL) { + dst->called_scope_name = zend_string_init(ZSTR_VAL(called_scope->name), ZSTR_LEN(called_scope->name), 1); + GC_MAKE_PERSISTENT_LOCAL(dst->called_scope_name); + } HashTable *static_vars = ZEND_MAP_PTR_GET(src_op->static_variables_ptr); if (!static_vars) { @@ -1658,6 +1684,15 @@ static void thread_release_closure_copy(thread_release_ctx_t *ctx, async_thread_ } } + if (copy->scope_name != NULL) { + zend_string_release(copy->scope_name); + copy->scope_name = NULL; + } + if (copy->called_scope_name != NULL) { + zend_string_release(copy->called_scope_name); + copy->called_scope_name = NULL; + } + if (copy->bound_vars) { zval *val; ZEND_HASH_FOREACH_VAL(copy->bound_vars, val) { @@ -1675,18 +1710,21 @@ static void thread_release_closure_copy(thread_release_ctx_t *ctx, async_thread_ /** * Create a snapshot: deep-copy closures into arena memory. + * entry_is_bootloader: the entry slot holds a bootloader (pool case) and is + * copied unscoped, like the regular bootloader slot. */ -async_thread_snapshot_t *async_thread_snapshot_create(const zend_fcall_t *entry, const zend_fcall_t *bootloader) +async_thread_snapshot_t *async_thread_snapshot_create( + const zend_fcall_t *entry, const zend_fcall_t *bootloader, bool entry_is_bootloader) { async_thread_snapshot_t *snapshot = pecalloc(1, sizeof(async_thread_snapshot_t), 1); thread_copy_ctx_t ctx; thread_copy_ctx_init(&ctx); - thread_copy_callable(&ctx, entry, &snapshot->entry); + thread_copy_callable(&ctx, entry, &snapshot->entry, entry_is_bootloader); if (bootloader != NULL && !EG(exception)) { - thread_copy_callable(&ctx, bootloader, &snapshot->bootloader); + thread_copy_callable(&ctx, bootloader, &snapshot->bootloader, true); } /* Store arena block list in snapshot — needed even for the failure path @@ -2311,11 +2349,56 @@ static void op_array_to_emalloc(zend_op_array *op_array) * separate efree(literals) call. */ } +/* Resolve a transferred scope class name in the current thread. The stored + * name is persistent (foreign) — lookup and autoload need a local copy. */ +static zend_class_entry *thread_resolve_scope_class(const zend_string *name) +{ + zend_string *local = zend_string_init(ZSTR_VAL(name), ZSTR_LEN(name), 0); + zend_class_entry *ce = zend_lookup_class(local); + zend_string_release(local); + + if (UNEXPECTED(ce == NULL)) { + zend_throw_error(NULL, + "Cannot restore closure scope: class \"%s\" not found in the target thread", + ZSTR_VAL(name)); + } + + return ce; +} + void async_thread_create_closure( const async_thread_closure_copy_t *copy, zval *closure_zv) { ZEND_ASSERT(copy->func != NULL); + /* Re-resolve the closure's scope here before building anything. A missing + * class is a hard error — silently dropping the scope would break + * self::/static:: and member visibility at first use. */ + zend_class_entry *scope = NULL; + zend_class_entry *called_scope = NULL; + + if (copy->scope_name != NULL) { + scope = thread_resolve_scope_class(copy->scope_name); + + if (UNEXPECTED(scope == NULL)) { + ZVAL_UNDEF(closure_zv); + return; + } + } + + if (copy->called_scope_name != NULL) { + if (scope != NULL && zend_string_equals(copy->called_scope_name, copy->scope_name)) { + called_scope = scope; + } else { + called_scope = thread_resolve_scope_class(copy->called_scope_name); + + if (UNEXPECTED(called_scope == NULL)) { + ZVAL_UNDEF(closure_zv); + return; + } + } + } + zend_function func; memcpy(&func, copy->func, sizeof(zend_op_array)); func.op_array.fn_flags &= ~ZEND_ACC_IMMUTABLE; @@ -2364,14 +2447,15 @@ void async_thread_create_closure( ZEND_MAP_PTR_INIT(func.op_array.run_time_cache, NULL); func.op_array.fn_flags &= ~ZEND_ACC_HEAP_RT_CACHE; - zend_class_entry *this_scope = NULL; zval *this_arg = NULL; if (Z_TYPE(loaded_this) == IS_OBJECT) { - this_scope = Z_OBJCE(loaded_this); this_arg = &loaded_this; } - zend_create_closure(closure_zv, &func, this_scope, this_scope, this_arg); + /* Kill the source thread's stale CE pointer before handing to the engine. */ + func.op_array.scope = scope; + + zend_create_closure(closure_zv, &func, scope, called_scope != NULL ? called_scope : scope, this_arg); /* zend_create_closure took its own ref on $this; release our load ref. */ if (Z_TYPE(loaded_this) == IS_OBJECT) { @@ -2407,29 +2491,34 @@ static bool thread_call_closure( zval closure_zv; async_thread_create_closure(copy, &closure_zv); - const zend_function *func = zend_get_closure_method_def(Z_OBJ(closure_zv)); - async_zend_closure_t *closure = (async_zend_closure_t *) Z_OBJ(closure_zv); - ZVAL_UNDEF(retval); - /* Execute closure directly via VM, bypassing zend_call_function. - * zend_call_function would trigger zend_throw_exception_internal - * when current_execute_data is NULL (no PHP caller above us), - * converting any uncaught exception into a fatal bailout. - * With zend_execute_ex we get the exception cleanly in EG(exception). */ - uint32_t call_info = ZEND_CALL_TOP_FUNCTION; - void *object_or_scope = NULL; - if (Z_TYPE(closure->this_ptr) == IS_OBJECT) { - call_info |= ZEND_CALL_HAS_THIS; - object_or_scope = Z_OBJ(closure->this_ptr); - } - zend_execute_data *frame = zend_vm_stack_push_call_frame( - call_info, (zend_function *) func, 0, object_or_scope); - zend_init_func_execute_data(frame, (zend_op_array *) &func->op_array, retval); - zend_execute_ex(frame); - - /* After zend_execute_ex returns, the frame is already freed by the VM. - * If the closure threw, EG(exception) is set — no bailout occurred. */ + if (EXPECTED(EG(exception) == NULL)) { + const zend_function *func = zend_get_closure_method_def(Z_OBJ(closure_zv)); + async_zend_closure_t *closure = (async_zend_closure_t *) Z_OBJ(closure_zv); + + /* Execute closure directly via VM, bypassing zend_call_function. + * zend_call_function would trigger zend_throw_exception_internal + * when current_execute_data is NULL (no PHP caller above us), + * converting any uncaught exception into a fatal bailout. + * With zend_execute_ex we get the exception cleanly in EG(exception). */ + uint32_t call_info = ZEND_CALL_TOP_FUNCTION; + void *object_or_scope = NULL; + if (Z_TYPE(closure->this_ptr) == IS_OBJECT) { + call_info |= ZEND_CALL_HAS_THIS; + object_or_scope = Z_OBJ(closure->this_ptr); + } else if (closure->called_scope != NULL) { + /* Scoped static call: the frame carries called_scope, no $this. */ + object_or_scope = closure->called_scope; + } + zend_execute_data *frame = zend_vm_stack_push_call_frame( + call_info, (zend_function *) func, 0, object_or_scope); + zend_init_func_execute_data(frame, (zend_op_array *) &func->op_array, retval); + zend_execute_ex(frame); + } + + /* EG(exception) is set when closure creation failed (e.g. scope class + * missing in this thread) or the closure threw — no bailout occurred. */ const bool has_exception = EG(exception) != NULL; if (UNEXPECTED(has_exception)) { zval exception_zval, transferred_exception; @@ -3065,7 +3154,7 @@ METHOD(finally) void *async_thread_snapshot_create_api( const zend_fcall_t *entry, const zend_fcall_t *bootloader) { - return async_thread_snapshot_create(entry, bootloader); + return async_thread_snapshot_create(entry, bootloader, false); } void async_thread_snapshot_destroy_api(void *snapshot) @@ -3094,6 +3183,8 @@ static zend_object *closure_transfer_obj( zend_fcall_t fcall; memset(&fcall, 0, sizeof(fcall)); fcall.fci_cache.function_handler = (zend_function *) func; + /* zend_closure has std as first member, so the cast is valid. */ + fcall.fci_cache.called_scope = ((async_zend_closure_t *) object)->called_scope; /* Preserve the closure's bound $this. thread_copy_callable reads the * bound instance from fci_cache.object; without this the snapshot is @@ -3107,7 +3198,7 @@ static zend_object *closure_transfer_obj( fcall.fci_cache.object = Z_OBJ_P(bound_this); } - async_thread_snapshot_t *snapshot = async_thread_snapshot_create(&fcall, NULL); + async_thread_snapshot_t *snapshot = async_thread_snapshot_create(&fcall, NULL, false); if (snapshot == NULL) { return NULL; } @@ -3133,6 +3224,18 @@ static zend_object *closure_transfer_obj( zval closure_zv; async_thread_create_closure(&snapshot->entry, &closure_zv); + if (UNEXPECTED(EG(exception) != NULL)) { + /* Scope class missing here or a bound value failed to load. + * Follow the default-loader convention: throw + stdClass fallback. */ + zval_ptr_dtor(&closure_zv); + async_thread_snapshot_destroy(snapshot); + object->properties = NULL; + + zend_object *fallback = zend_objects_new(zend_standard_class_def); + object_properties_init(fallback, zend_standard_class_def); + return fallback; + } + /* Copy op_array internals from persistent arena into emalloc * so the closure is fully self-contained */ async_zend_closure_t *closure = (async_zend_closure_t *) Z_OBJ(closure_zv); diff --git a/thread.h b/thread.h index 95a752f..4631266 100644 --- a/thread.h +++ b/thread.h @@ -46,6 +46,8 @@ typedef struct _async_thread_closure_copy_t { zend_op_array *func; HashTable *bound_vars; /* NULL if no captured variables (pemalloc, not arena) */ zval bound_this; /* IS_UNDEF if closure has no $this binding (pemalloc) */ + zend_string *scope_name; /* declaring class of the closure; NULL if unscoped (pemalloc) */ + zend_string *called_scope_name; /* late-static-binding scope; NULL if none (pemalloc) */ } async_thread_closure_copy_t; typedef struct _async_thread_snapshot_t { @@ -66,7 +68,7 @@ typedef struct _async_thread_snapshot_t { * @return Snapshot (caller owns, free with async_thread_snapshot_destroy) */ async_thread_snapshot_t *async_thread_snapshot_create( - const zend_fcall_t *entry, const zend_fcall_t *bootloader); + const zend_fcall_t *entry, const zend_fcall_t *bootloader, bool entry_is_bootloader); /** * Free a snapshot and all its resources. diff --git a/thread_pool.c b/thread_pool.c index 5a4fa4b..f7b309b 100644 --- a/thread_pool.c +++ b/thread_pool.c @@ -887,7 +887,7 @@ zend_async_thread_pool_t *async_thread_pool_create( * entry — each task brings its own. */ pool->bootloader_snapshot = NULL; if (bootloader != NULL) { - pool->bootloader_snapshot = async_thread_snapshot_create(bootloader, NULL); + pool->bootloader_snapshot = async_thread_snapshot_create(bootloader, NULL, true); if (UNEXPECTED(pool->bootloader_snapshot == NULL)) { /* snapshot_create propagated an exception (e.g. captured value * refused transfer). Fail construction. */ @@ -1186,7 +1186,7 @@ METHOD(submit) /* 1. Create snapshot — deep-copies closure op_array + bound vars */ const zend_fcall_t fcall = { .fci = fci, .fci_cache = fcc }; - async_thread_snapshot_t *snapshot = async_thread_snapshot_create(&fcall, NULL); + async_thread_snapshot_t *snapshot = async_thread_snapshot_create(&fcall, NULL, false); if (UNEXPECTED(snapshot == NULL)) { zend_throw_exception(async_ce_thread_pool_exception, "Failed to create task snapshot", 0); @@ -1295,7 +1295,7 @@ METHOD(map) ZEND_HASH_FOREACH_KEY_VAL(ht, num_key, str_key, item) { const zend_fcall_t fcall = { .fci = fci, .fci_cache = fcc }; - async_thread_snapshot_t *snapshot = async_thread_snapshot_create(&fcall, NULL); + async_thread_snapshot_t *snapshot = async_thread_snapshot_create(&fcall, NULL, false); if (UNEXPECTED(snapshot == NULL)) { zval_ptr_dtor(&futures_arr);