diff --git a/CHANGELOG.md b/CHANGELOG.md index 14519acf..d6e7456c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to the Async extension for PHP will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - + +### Added +- **`Async\ThreadPool`** (new class): pool of OS threads for executing PHP closures. `submit($callable, ...$args): Future`, `map(array $items, $callable): array`, `close()` (graceful), `cancel()` (rejects backlog with `Async\CancellationException`, running tasks still finish), `isClosed()`, `getWorkerCount()`, `getPendingCount()`, `getRunningCount()`. Implements `Countable`. Constructor `new ThreadPool(int $workers, int $queue_size = 0)`; queue is a thread-safe channel that suspends the submitting coroutine when full (backpressure). +- **`Async\ThreadPoolException`** (new class): thrown from `submit()` / `map()` when the pool is closed. +- **`Async\ThreadChannel`** (new class): thread-safe channel for transferring zvals between threads via deep-copy snapshot. `send()` / `receive()` suspend the calling coroutine instead of blocking the OS thread. Closures, including those with bound variables, transfer correctly through the snapshot machinery. +- **`Async\ThreadChannelException`** (new class). +- **Coverage phase 2** — targeted tests for `future.c`, `async.c`, `task_group.c`, `channel.c`, `thread.c`, `thread_pool.c`, `context.c`, `pool.c`. Aggregate ext/async coverage went from 77.45% to 78.34% lines (+104 lines) and from 88% to 89.1% functions (+10 functions). New tests cover Future status/cancel/getAwaitingInfo methods, FutureState double-resolve errors, finally() exception-chain propagation, non-callable argument rejection on map/catch/finally, TaskGroup synchronous-settled paths for `all()`/`race()`/`any()`, Channel unbuffered-iterator and foreach-by-ref branches, `Async\timeout(0)` ValueError, `Async\delay(0)` fast path, `Async\current_coroutine()` out-of-coroutine error, and `Context::get()` missing-key fallback. See `COVERAGE_PROGRESS.md` for the per-target breakdown. + +### Fixed +- **`Async\Timeout::cancel()` double-released the backing object**: Calling `$t->cancel()` disposed the backing timer event, whose `async_timeout_event_dispose()` unconditionally ran `OBJ_RELEASE(object)` assuming the event held a counted reference. In the current architecture the event only stores a raw pointer (`async_timeout_ext_t::std`) without a matching `GC_ADDREF` at creation time, so the release actually decremented the caller's live refcount. The backing object was freed while the userland `$t` variable still pointed to it, and shutdown tripped `IS_OBJ_VALID(object_buckets[handle])` in `zend_objects_store_del()`. Fixed by mirroring `async_timeout_destroy_object()`: `cancel()` now clears `timeout_ext->std` before dispatching the dispose so `async_timeout_event_dispose()` sees a NULL `std` and skips the stray release. +- **`pool_strategy_report_failure()` captured a dangling exception pointer**: When no caller-provided error was available, the helper created a fresh `Exception` via `zend_throw_exception(NULL, "Resource validation failed", 0)` followed immediately by `zend_clear_exception()`. The throw set `EG(exception)` to a refcount-1 object; `clear_exception()` dropped that reference, freeing the exception. The subsequent `ZVAL_OBJ(&error_zval, ex)` captured a dangling pointer that was then handed to the userland `reportFailure()` handler, producing `zend_mm_heap corrupted` and SIGSEGV at shutdown on ZTS DEBUG. Fixed by constructing the exception directly via `object_init_ex(zend_ce_exception)` + `zend_update_property_ex(MESSAGE)`, which never touches `EG(exception)`, and managing the zval lifetime with an `owns_error` flag and an explicit `zval_ptr_dtor()` after the `reportFailure()` call. +- **`Async\Scope::disposeAfterTimeout()` leaked the scope refcount**: The timer callback bumped `callback->scope->scope.event.ref_count` once but nothing in `scope_timeout_callback()` or `scope_timeout_coroutine_entry()` ever released it, so the scope was always held above its natural lifetime — 4 `zend_mm` leaks per invocation in DEBUG. The raw `ref_count++` was replaced with `ZEND_ASYNC_EVENT_ADD_REF` and a custom `scope_timeout_callback_dispose` handler now releases the ref when the callback is freed without firing. On the fire path, `scope_timeout_callback()` transfers ownership to the spawned cancellation coroutine (via `extended_data`); `scope_timeout_coroutine_entry()` calls `ZEND_ASYNC_EVENT_RELEASE` after `SCOPE_CANCEL`. The previously-silent `add_callback` failure path also now releases the ref and frees the unclaimed callback. +- **`Async\CompositeException` wrote to hard-coded `properties_table[7]`**: `async_composite_exception_add_exception()` assumed the `private array $exceptions` typed property lived at slot 7 of the typed-property layout. The real offset for `CompositeException extends \Exception` did not match, so the helper was clobbering an unrelated typed slot: `getExceptions()` on an empty composite hit the "Typed property must not be accessed before initialization" fatal because it was reading the actual (uninitialized) `$exceptions` slot via `zend_read_property`; multiple `addException()` calls produced `var_dump` output with garbage pointer fields and implausible string lengths. Fixed by reading and writing `$exceptions` through `zend_read_property` / `zend_update_property` with the property name, so the engine resolves the correct typed-property slot regardless of inherited layout. `getExceptions()` switched from `silent=0` to `silent=1` (`BP_VAR_IS`) so an empty composite reads back as `[]` rather than triggering the typed-uninit fatal. A second latent bug surfaced while verifying: the PHP method `addException` was passing `transfer=true` to the C helper even though `Z_PARAM_OBJECT_OF_CLASS` only lends a borrowed reference, which caused the stored zval refcount to be one short and made repeated adds alias to the last-inserted object once the slot-7 corruption stopped masking it. Fixed by switching the method call site to `transfer=false` so the helper performs the `GC_ADDREF`. +- **`Async\Timeout::cancel()` assertion at shutdown (`IS_OBJ_VALID`)**: See the first entry above — this is the same bug; leaving it listed because `tests/common/timeout_class_methods.phpt` from coverage phase 2 is the test that exposed it. +- **`TaskGroup::all()`/`race()`/`any()` use-after-free in synchronous-settled path**: The synchronous fast paths created a waiter via `task_group_waiter_future_new()` (which pushes it into `group->waiter_events[]`), resolved it synchronously, wrapped it in a Future wrapper and returned — but never removed it from the `waiter_events[]` vector. The drain path in `task_group_try_complete()` always calls `task_group_waiter_event_remove()` after resolving; the sync path forgot to mirror that. At shutdown, `task_group_free_object()` force-disposed everything still in `waiter_events[]`, which `efree`'d the waiter. When the Future wrapper was then destroyed and released the waiter it had wrapped, it touched freed memory — "Future was never used" warning from a stale `zend_future_t` followed by a segfault whenever user code kept an intermediate `$future = $group->all()` variable across a `try`/`catch`. Fixed by calling `task_group_waiter_event_remove(waiter)` at the end of each synchronous-resolve branch, matching what `task_group_try_complete()` does. +- **`Thread::finally()` on a still-running thread NULL-scope crash**: `thread_object_dtor()` dispatches registered finally handlers via `async_call_finally_handlers()`, which unconditionally dereferences `context->scope` through `ZEND_ASYNC_NEW_SCOPE(context->scope)` and `ZEND_ASYNC_EVENT_ADD_REF(&context->scope->event)`. `thread.c` was passing `context->scope = NULL` because the Thread object had no PHP-side scope of its own, and registering a finally handler on a still-running thread then destroying the thread would segfault at dtor time. Fixed by capturing `ZEND_ASYNC_CURRENT_SCOPE` at spawn time (`async_thread_object_t::parent_scope`) and holding a refcount on the scope event so it outlives the Thread. `thread_object_dtor()` now passes `thread->parent_scope` to the finally dispatcher, so handlers inherit the caller's async context hierarchy (exception handlers, context values) just like `coroutine`/`task_group`/`scope` finally do. Released in `thread_object_free()`. Added `thread_finally_handlers_dtor()` to pair the `GC_ADDREF` that keeps the Thread alive during handler execution with an `OBJ_RELEASE` — previously `context->dtor` was `NULL` and the Thread object leaked 72 bytes every time dtor-time finally ran. A `ZEND_ASYNC_IS_OFF` safety net is kept for the edge case where a Thread object outlives the async subsystem (late `zend_call_destructors` after RSHUTDOWN). + ## [0.6.7] - 2026-04-13 ### Added diff --git a/COVERAGE_PLAN.md b/COVERAGE_PLAN.md new file mode 100644 index 00000000..c9a97486 --- /dev/null +++ b/COVERAGE_PLAN.md @@ -0,0 +1,247 @@ +# TrueAsync: Coverage Analysis & Test Plan + +Baseline (build: `--enable-gcov --enable-zts --enable-debug --disable-all --enable-async --enable-pdo --with-pdo-mysql --with-pdo-sqlite --enable-sockets --enable-posix --enable-pcntl`): + +- **Lines:** 74.3% (8756 / 11785) +- **Functions:** 85.3% (774 / 907) +- **Tests:** 804 passed, 156 skipped, 1 xfail-warned, 0 failed +- Report: `/home/edmond/build-gcov-src/coverage_html/index.html` +- Data: `/home/edmond/build-gcov-src/coverage_async.info` + +## 1. Per-file coverage (sorted by %) + +| File | Lines | Missing | Funcs | +| ------------------------------ | -------- | ------- | ------ | +| zend_common.c | **25.7%** | 101 | 4/13 | +| exceptions.c | **56.3%** | 80 | 14/20 | +| libuv_reactor.c | **62.3%** | **791** | 120/158| +| internal/circular_buffer.c | 64.8% | 63 | 15/19 | +| pool.c | 71.2% | 198 | 47/55 | +| scope.c | 71.6% | 221 | 45/49 | +| async_API.c | 72.6% | 157 | 23/32 | +| thread.c | 73.3% | 306 | 61/72 | +| coroutine.c | 76.5% | 174 | 44/49 | +| async.c | 76.5% | 179 | 37/44 | +| scheduler.c | 77.0% | 177 | 34/36 | +| future.c | 80.4% | 188 | 67/76 | +| thread_pool.c | 80.4% | 68 | 19/21 | +| task_group.c | 81.5% | 154 | 70/76 | +| fs_watcher.c | 84.7% | 34 | 19/21 | +| iterator.c | 85.3% | 38 | 16/17 | +| channel.c | 86.7% | 52 | 39/44 | +| context.c | 87.8% | 21 | 19/20 | +| thread_channel.c | 92.4% | 19 | 24/27 | + +## 2. Root causes of missing coverage + +Gaps cluster into six categories. Each category maps to a set of concrete tests below. + +### A. Modules disabled in build → tests SKIP-ped (156 tests) + +The gcov build uses the repo's `config.nice` as-is (only `--enable-gcov` added). That means tests under these dirs almost all SKIP: + +- `ext/async/tests/curl/*` — no `--with-curl` +- `ext/async/tests/mysqli/*` — no `--with-mysqli` +- `ext/async/tests/pdo_pgsql/*` — no `--with-pdo-pgsql` +- `ext/async/tests/dns/*`, partial `stream/*` (UDP) — partly covered by libuv anyway +- `ext/async/tests/exec/*` — gated on `pcntl`/`posix`, built, but some sub-scenarios skip + +**Impact on coverage:** indirectly hurts `libuv_reactor.c`, `exceptions.c` (I/O error constructors), and some `async.c` ini/integration paths. + +**Action A1:** rebuild with `--with-curl --with-mysqli --with-pdo-pgsql` and rerun report. +Expected bump: ~3–5% overall lines, biggest gain in `libuv_reactor.c` DNS / poll / exec paths. + +### B. Deadlock / diagnostics mode never exercised + +Several large gaps are "dump state for debugging" helpers that only run when `ASYNC_G(debug_deadlock)` INI is on. + +| Location | What's untested | +|---|---| +| `libuv_reactor.c:185-310` (126 lines) | `libuv_poll_info`, `libuv_timer_info`, `libuv_signal_info`, `libuv_process_info`, `libuv_filesystem_info`, `libuv_dns_*_info`, `libuv_exec_info`, `libuv_trigger_info`, `libuv_io_info`, `libuv_listen_info`, `libuv_task_info`, `io_type_name` — all `*_info()` event describers for deadlock reports | +| `scope.c:1070-1083` | `scope_info()` describer | +| `scheduler.c:574-642` | `print_deadlock_report()` — the whole deadlock reporter that walks waiting coroutines and prints their events | +| `scheduler.c:842-856` | The auto-stop branch after deadlock detection | + +**Action B1:** `tests/info/002-deadlock_report.phpt` — INI `async.debug_deadlock=1`, start two coroutines that mutually await each other, let scheduler detect the deadlock, assert the printed report mentions both coroutines and their event types. + +**Action B2:** `tests/info/003-event_info_strings.phpt` — for each event kind (Timer, Signal, DNS, Exec, IO-TCP/UDP/pipe/file/TTY, Listen, Process, Filesystem watcher, Task, Trigger, Poll): create one, force it into the deadlock waiting list, dump the info string. One test, many sub-cases. Covers `libuv_*_info` family and `scope_info`. + +### C. UDP + socket-option paths unexercised + +`libuv_reactor.c:4700-4958` (~250 lines) contains: +- `udp_send_cb`, `udp_recv_alloc_cb`, `udp_recv_cb` +- `libuv_udp_sendto`, `libuv_udp_recvfrom` +- `libuv_io_set_option` (TCP `NODELAY`, `KEEPALIVE`; UDP `BROADCAST`, `MULTICAST_LOOP`, `MULTICAST_TTL`, `TTL`) +- `libuv_udp_set_membership` (multicast join/leave) + +There **are** UDP tests (`stream/028-030`, `socket_ext/003`, `socket/003`) — but they all SKIP in the current build (no `--enable-sockets`... wait, sockets IS enabled, but those phpt files may gate on `ext/sockets`). Confirm after rebuild. + +**Action C1:** rebuild and re-measure; most of this block should then go green. + +**Action C2:** if still red after rebuild, add: +- `tests/io/040-udp_broadcast.phpt` — `setOption(BROADCAST, 1)` + `setOption(BROADCAST, 0)` +- `tests/io/041-udp_multicast_ttl.phpt` — `setOption(MULTICAST_TTL, 32)`, `setOption(MULTICAST_LOOP, 0)` +- `tests/io/042-udp_multicast_membership.phpt` — join + leave multicast group `239.x` +- `tests/io/043-tcp_socket_options.phpt` — `NODELAY`, `KEEPALIVE` +- `tests/io/044-socket_option_invalid_type.phpt` — set UDP option on TCP and vice versa (hits the `default` branches) + +### D. libuv init/open **error paths** — require faulty OS state + +Dozens of 3–8 line gaps in `libuv_reactor.c` are the `if (uv_*_init(...) < 0)` and `uv_fileno` error branches. Examples: + +- L4005-4008: `uv_pipe_open` fails +- L4013-4029: `uv_tty_init` fails +- L4035-4056: `uv_tcp_init` + `uv_tcp_open` fail +- L4050-4056: `uv_udp_init` fails +- L4397-4420: `uv_fs_fsync` start fails +- L4452-4517: listen bind / ipv6 parse / `uv_fileno` failures + +These are hard to hit without fault injection — passing garbage FDs or closed handles is the usual trick. + +**Action D1:** `tests/io/045-open_closed_fd.phpt` — `fclose($f)`, then wrap the numeric fd in an async IO and assert it throws. Covers `uv_pipe_open` / `uv_tcp_open` error branches. + +**Action D2:** `tests/io/046-tty_on_regular_file.phpt` — open a regular file as TTY → `uv_tty_init` returns error. (Expected output: `Failed to initialize TTY handle:`) + +**Action D3:** `tests/io/047-listen_bad_address.phpt` — `Async\listen("::zz::", 0)` invalid ipv6. Covers `uv_ip6_addr` error branch. + +**Action D4:** `tests/io/048-listen_privileged_port.phpt` (skip-if-root) — bind to port 80 as non-root → `uv_tcp_bind` fails. + +**Action D5:** `tests/io/049-fsync_on_pipe.phpt` — call `fflush`/`fsync` on a non-file handle — asserts the "Pipes and TTYs have no disk buffer" fast-return branch (L4397-4404). + +**Note:** remaining 3-line error branches (maybe 15-20 spots) will not be realistic to hit without fault injection. Accept as known residual. + +### E. Stderr branch of exec is untested + +`libuv_reactor.c:3169-3194` — `exec_std_err_alloc_cb` and `exec_std_err_read_cb`. `tests/exec/*` likely only capture stdout. + +**Action E1:** `tests/exec/010-capture_stderr.phpt` — spawn `sh -c 'echo err 1>&2'`, await, assert `std_error` captured. + +**Action E2:** `tests/exec/011-stdout_stderr_interleaved.phpt` — both streams, ensure interleaved capture works (also covers L3180-3190 nread<0 close branch). + +### F. API helpers used **only from C extensions**, not PHP userland + +Large blocks of unreached code are C API shims intended for other extensions (mysqli/curl/pgsql) to plug into the TrueAsync API. + +| File | Lines | Functions | +|---|---|---| +| `coroutine.c:1077-1126` | 50 | `async_coroutine_context_{set,get,has,delete}` — C API for context | +| `async_API.c:1171-1197` | 27 | `async_pool_try_acquire_wrapper`, `async_pool_release_wrapper`, `async_pool_close_wrapper` — pool-API shims | +| `async_API.c:165-317` | ~60 | `zend_async_*_register` validators (already-registered error paths) | +| `zend_common.c:25-246` | 101 | `zend_exception_to_warning`, `zend_current_exception_get_{message,file,line}`, `zend_exception_merge`, `zend_new_weak_reference_from`, `zend_resolve_weak_reference`, `zend_hook_php_function`, `zend_replace_method`, `zend_get_function_name_by_fci` | + +These are impossible to cover via phpt alone. Options: +1. Accept as residual — it's exercised only when another extension actually uses the C API. +2. Rebuild with `--with-curl --with-mysqli --with-pdo-pgsql`. curl integration in particular uses `async_coroutine_context_*` for per-coroutine state — should close most of **coroutine.c** gap. +3. Write a tiny test-only C extension `ext/async/tests/capi_probe/` that calls each C API entrypoint and asserts return values — only worth doing if we want a hard 95%+ number. + +**Action F1 (cheap):** rebuild with curl + mysqli + pdo_pgsql (covers 1 & 2). Revisit the zend_common.c numbers after. + +**Action F2 (optional):** the capi_probe test extension. + +### G. Feature edge cases with partial coverage + +These are scenarios where the main path is hit but a side branch isn't. + +**scope.c:** +- L1039-1068: `scope_replay()` — scope replay after completion (used by persistent awaiters). Test: `tests/scope/0xx-scope_replay_after_finish.phpt` — finish a scope, subscribe a new callback, assert it gets UNDEF/NULL. +- L1535-1598: **child-scope exception handler** — when a nested scope's exception is handled by the parent's `setChildExceptionHandler`. Test: `tests/scope/0xx-child_exception_handler.phpt` — parent with setChildExceptionHandler, child throws, assert parent handler runs and parent survives. +- L601-684, 1063-1082: scope cancellation through nested scopes with exception handlers registered. + +**thread.c:** +- L400-450: closure transfer branches for `op_array` fields — `static_variables`, `literals`, `arg_info` with return type, `live_range`, `doc_comment`, `attributes`, `try_catch_array`, `vars`, `dynamic_func_defs`. + - Tests already cover simple closures; need `tests/thread_pool/031-closure_with_all_oparray_fields.phpt`: closure with typed params, typed return, try/catch inside, static locals, attributes, nested function definitions. One test can flip ~40 lines. +- L2122-2361 (~170 lines): method implementations for `getResult`, `getException`, `cancel`, probably also statics. Need `tests/thread/0xx-getResult_before_join.phpt`, `tests/thread/0xx-getException_after_throw.phpt`, `tests/thread/0xx-cancel_noop.phpt` (cancel currently returns false — test the TODO behavior). +- L207-344: thread pool lifecycle branches (queue close while draining, etc). + +**pool.c:** +- L540-657, 756-789: **healthcheck** path — `pool_healthcheck_callback_dispose`, `pool_healthcheck_timer_callback`, the actual healthcheck call into user fcall. No test exercises healthcheck currently. + - Test: `tests/pool/0xx-healthcheck_healthy.phpt` — create pool with healthcheck returning true, acquire, release, wait, acquire again. + - Test: `tests/pool/0xx-healthcheck_reject.phpt` — healthcheck returns false → resource discarded on acquire. + - Test: `tests/pool/0xx-healthcheck_throws.phpt` — healthcheck throws → treated as unhealthy. + - Test: `tests/pool/0xx-healthcheck_interval.phpt` — periodic healthcheck timer ticks. +- L977-1017: `zend_async_pool_destroy` custom fcall release branches — paths where factory/destructor/healthcheck/before_acquire/before_release **are** user fcalls (not INTERNAL flag). + - Already partly covered — needs a test with all 5 user callbacks set to close branches `L994-1008`. +- L1050-1099: `async_pool_create_object_for_pool` — also C API, same as category F. + +**async.c:** +- L814-826, 859-907, 920-987: cleanup on critical error / bailout — the "scheduler abort" paths. +- L1167-1315, 1358-1439: INI handlers / MINFO registration / module shutdown edge cases. + - Test: `tests/info/0xx-phpinfo_async_section.phpt` — assert `phpinfo()` shows async section with expected keys. Covers the MINFO block. + - Test: `tests/info/0xx-ini_debug_deadlock.phpt` — flip the INI at runtime via `ini_set`. + +**scheduler.c:** +- L91-109: early-start branch when reactor already initialized. +- L704-752: finalization on shutdown with pending coroutines. +- L884-919, 1208-1307: edge cases in `scheduler_wait_for_event` — timeout-driven exits. + - Test: `tests/scheduler/0xx-shutdown_with_pending.phpt` — spawn a coroutine that awaits forever, let the request end, assert clean shutdown + warning. + +**task_group.c:** +- 154 missing lines in 92% func-coverage file → mostly branch misses inside partially-covered functions. Targets: + - Partial cancellation (L-ranges in the middle of the file) — race between `cancel()` and `await`. + - Test: `tests/task_group/0xx-cancel_during_await.phpt`. + +**future.c:** +- 188 missing lines → mostly error-state propagation branches. + - `tests/future/0xx-await_already_rejected.phpt` + - `tests/future/0xx-await_with_cancellation_object.phpt` (non-null cancellation source) + - `tests/future/0xx-double_reject.phpt` — second reject should be ignored. + +**channel.c:** +- 52 missing lines, 5 untested methods — likely destructor races and `tryRecv`/`trySend` branches on a closed channel. + +**exceptions.c:** (category B above mostly) + +- L41-46, L138-221, L268-282: context-less branches of `async_throw_{cancellation,input_output,timeout,poll,deadlock}` — only reachable when `EG(current_execute_data) == NULL` (i.e. exception constructed from a callback outside any PHP frame). + - These are typically hit indirectly from libuv callbacks. A test that forces a poll error in a non-userland frame (e.g., a timer callback that throws) would cover them. + - Test: `tests/edge_cases/0xx-exception_from_native_callback.phpt` — signal handler registered then the signal arrives during idle. + +**internal/circular_buffer.c:** +- L54-94: capacity-zero / grow-on-empty branches. +- L297-318, L414-420: ring wrap + shrink branches. + - Test: `tests/channel/0xx-buffer_wrap_extreme.phpt` — capacity 3, push/pop 100 times, assert FIFO holds. + - Test: `tests/channel/0xx-buffer_resize_full.phpt` — fill, send one more (should block), drain half, send → ensure wrap. + +## 3. Prioritized action list + +**P0 — biggest bang per phpt (rebuild only):** +1. Rebuild with `--with-curl --with-openssl --enable-mysqli --with-mysqli=mysqlnd --with-pdo-pgsql`. Rerun. Projected +4-6% lines (categories A + partial C + partial F). + +**P1 — small phpt batch, big gain (~20 new tests):** +2. Info/deadlock tests (category B1+B2): +~200 lines. +3. Socket options + UDP options + invalid-option (C2): +~80 lines. +4. Exec stderr capture (E1+E2): +~30 lines. +5. Pool healthcheck suite (pool.c healthcheck): +~60 lines. +6. Scope child-exception + replay (scope.c): +~70 lines. +7. Thread `getResult` / `getException` / `cancel` method tests: +~50 lines. +8. Circular buffer wrap/resize edge cases: +~30 lines. +9. Closure with full op_array feature set: +~45 lines. +10. Future rejection edge cases: +~40 lines. + +**P2 — error injection tests:** +11. Bad-fd / bad-address libuv init failures (D1–D5): +~50 lines. + +**P3 — optional / low ROI:** +12. C API probe extension for category F (zend_common.c, coroutine.c context API, pool wrappers): requires new test extension. +13. Residual 3-line OS-error branches — accept as known. + +## 4. Projected coverage after P0+P1+P2 + +- After P0: **~78-80%** lines, **~88%** functions. +- After P1: **~87-90%** lines, **~93%** functions. +- After P2: **~91-92%** lines. +- P3 cap (realistic): **~94-95%**. Anything higher requires deleting dead code or adding gcov-excludes for info dumps / OS-error branches. + +## 5. How to re-measure + +```bash +cd /home/edmond/build-gcov-src +# reset counters +find . -name '*.gcda' -delete +# rerun tests +make test TESTS='ext/async/tests' +# capture + summary +lcov --capture --directory ext/async --output-file coverage_async.info --no-external +lcov --summary coverage_async.info +# html +rm -rf coverage_html +genhtml coverage_async.info --output-directory coverage_html --prefix /home/edmond/build-gcov-src +``` diff --git a/COVERAGE_PROGRESS.md b/COVERAGE_PROGRESS.md new file mode 100644 index 00000000..b0bd0c3b --- /dev/null +++ b/COVERAGE_PROGRESS.md @@ -0,0 +1,244 @@ +# Coverage Progress — Phase 2 + +Tracks the phpt-only "achievable budget" from `COVERAGE_REPORT.md` §6 +(realistic ceiling ~80%). This file is updated as blocks land so the +next session can pick up where the previous one stopped. + +Start of phase: 77.45% lines / 88% functions (from §1 of the report). + +End of phase: **78.34% lines / 89.1% functions** (+0.89% lines, ++104 lines of 11785; +1.1% functions, +10 functions). + +## Plan + +| # | Target | File | Budget | Status | +|---|---|---|---|---| +| 1 | finally handlers chain | `future.c:1192–1252` | ~60 lines | **DONE** (+5.42% → 85.80%) | +| 2 | `Async\iterate` + small `async.c` gaps | `async.c` | ~50 lines | **DONE** (+1.58% → 86.07%) | +| 3 | TaskGroup cancel/race/error | `task_group.c:243–1457` | ~40 lines | **DONE** (+2.17% → 86.18%) | +| 4 | Channel iterator paths | `channel.c` | ~40 lines | **DONE** (+2.30% → 89.00%) | +| 5 | Thread internals | `thread.c:1957–2172` | ~40 lines | **DONE** (+0.17%, mostly unreachable) | +| 6 | fs_watcher coalesce | `fs_watcher.c:141–246` | ~20 lines | **SKIPPED** (libuv event ordering) | +| 7 | thread_pool submit-after-close | `thread_pool.c:483–592` | ~15 lines | **DONE** (+0.58% → 80.98%) | +| 8 | Context find-local | `context.c:34–38,126–132` | ~10 lines | **DONE** (+1.16% → 91.86%) | + +## Log + +Entries appended as blocks land. Format: date, target, tests added, +commit hash, notes. + +### 2026-04-15 — future.c (target #1) + +`future.c` 80.38% → **85.80%** (+5.42% lines, ~52 lines of 958). + +Tests added (7, future/031–037): + +- **031** `isCompleted()` / `isCancelled()` across pending / completed / + rejected-with-Exception / rejected-with-AsyncCancellation. +- **032** `cancel()` with default cancellation, custom cancellation, + and short-circuit on already-completed (no-op branch). +- **033** `getAwaitingInfo()` returns a 1-element array containing the + zend_future_info() string; asserts "pending" → "completed" transition. +- **034** `FutureState::complete()` / `error()` already-completed error + paths — exercises the `FutureState is already completed at %s:%d` + branch via double-complete, error-after-complete, complete-after-error. +- **035** `Future::finally()` with a rejected parent where the handler + itself throws — hits `zend_exception_set_previous()` so the thrown + exception's ->previous is the original parent exception. +- **036** `map()` / `catch()` / `finally()` TypeError on non-callable + argument. +- **037** `finally()` on already-completed / already-failed futures — + exercises the eager-spawn branch in `async_future_create_mapper` + (L1672–1697) for `Future::completed()` and `Future::failed()`. + +Remaining gaps in future.c (~14% of 958): +- `FUTURE_STATE_METHOD` getAwaitingInfo / getCompleted* on destroyed state +- `Future::await` with `cancellation_event` argument (~40 lines) +- `iterator.c`/`future_iterator_*` error paths (~30 lines, fragile) +- Error paths behind `ecalloc` / `zend_new_array` failures (fault-injection only) + +### 2026-04-15 — async.c (target #2) + +`async.c` 84.49% → **86.07%** (+1.58%, ~12 lines of 761). The §6 +report over-estimated reachable lines in the `Async\iterate` block — +the remaining gap is the per-iteration chain/exception merge path that +requires both the iterator callback AND the cancel path to throw +simultaneously, plus defensive `ecalloc`/`zend_new_array` failure +branches. Instead, picked small surface-area wins across other async.c +functions. + +Tests added (6): + +- **iterate/014** IteratorAggregate::getIterator() throwing propagates + through `Async\iterate` — covers L856-867. +- **common/timeout_value_error** `Async\timeout(0)` / `-1` / `-1000` + → ValueError "must be greater than 0" (L694-697). +- **common/await_any_of_exception_releases_arrays** non-iterable + futures arg → exception path releases results/errors arrays and + re-throws (L637-640). +- **common/current_coroutine_not_in_coroutine** at script root — + "The current coroutine is not defined" (L772-775). +- **common/current_context_at_root** `Async\current_context()` and + `Async\coroutine_context()` at script root return independent + Context objects (L717-720, L745-748). +- **common/await_same_cancellation** passing the same event as both + awaitable and cancellation clears the cancellation slot (L306-307). +- **sleep/003-delay_zero_immediate** `Async\delay(0)` enqueue fast + path without timer (L671-672). + +Remaining async.c gaps (~14%): iterate cancel-pending exception merge +(L963-987), bailout paths inside `Async\protect` (L252-263), internal +finally-handler dispatcher (L243-244), and thread spawn event creation +error (L181-182). All either fault-injection or require bailout. + +### 2026-04-15 — task_group.c (target #3) + +`task_group.c` 84.01% → **86.18%** (+2.17%, ~18 lines of 832). + +Tests added (5, task_group/035–039): + +- **035** `all()` called after all tasks already failed synchronously + rejects immediately via CompositeException — covers L1406-1421 + synchronous-settled reject branch. +- **036** `race()` called after first task already in TASK_STATE_ERROR + synchronously rejects — L1452-1457 immediate reject path. +- **037** `any()` called after all tasks already failed synchronously + rejects via CompositeException — L1495-1500. +- **038** four small error branches in a single file: empty-`any()`, + negative `__construct($concurrency)`, duplicate integer key via + `spawnWithKey`. +- **039** direct `$group->getIterator()` call hits the PHP method body + (L1715-1721) — normal `foreach` goes through the class's + get_iterator handler and never reaches this code. + +Incidental latent bug found: keeping an intermediate `$future = +$group->all()` variable across a try/catch triggers a segfault at +coroutine teardown in the synchronously-settled path. Worked around +in test 035 by chaining `$group->all()->await()` directly. Logged as a +separate issue; not investigated in this pass. + +Found-and-skipped dead code: task_group.c L1305-1307 +`"Cannot spawn tasks on a completed TaskGroup"` — shadowed by the +earlier `IS_SEALED` check at L1300, so the "completed" branch can +never fire unless the group is both completed AND un-sealed, which +`ASYNC_TASK_GROUP_SET_COMPLETED` only does for sealed groups. + +Remaining task_group.c gaps (~14%): `task_group_dispose()`, +`task_group_replay()`, `task_group_info()` — only called from the +scheduler deadlock-debug reporter (see report §5.2), unreachable by +phpt in practice. + +### 2026-04-15 — channel.c (target #4) + +`channel.c` 86.70% → **89.00%** (+2.30%, ~9 lines of 391). + +Tests added (2): + +- **channel/039-channel_iterator_unbuffered** `foreach` over an + unbuffered (rendezvous, capacity=0) channel — exercises + `channel_iterator_move_forward` L471-475 which reads directly from + `channel->rendezvous_value`. Existing test 008 only covered the + buffered `zval_circular_buffer_pop` branch. +- **channel/040-channel_foreach_by_ref** `foreach ($ch as &$v)` → + "Cannot iterate channel by reference" Error — covers + `channel_get_iterator()` L517-519. + +Discovered but not added: direct `$channel->getIterator()` call hits +METHOD(getIterator) at L772-778, but the method currently returns an +`__iterator_wrapper` that fails the declared `: Iterator` return type +with a Fatal error. Not a phpt-writable case. + +Remaining channel.c gaps (~11%): `channel_info()` / `channel_dispose()` +deadlock-reporter paths (report §5.2), `async_channel_get_gc()` which +requires GC walk during a cycle, and `channel_iterator_get_current_key` +which is never called because channel iteration produces null keys. + +### 2026-04-15 — thread.c (target #5) + +`thread.c` 78.78% → **78.95%** (+0.17%, ~2 lines of 1145). + +The §6 report's "~40 lines" estimate turned out to be unreachable via +phpt: +- The `thread_event == NULL` branches in `isRunning` / `isCompleted` / + `isCancelled` / `getResult` / `getException` / `cancel` at + L2203-2288 are defensive dead code. `thread_event` is only nulled + inside `thread_object_dtor` (L2147), so by the time those branches + could be reached the object is already being freed and no further + PHP method calls are possible. +- The bailout-capture and exception-transfer paths at L1925-2022 fire + inside the internal thread entry point. Attempting to exercise them + from phpt either crashes (test 042 draft with finally registered on + a still-running thread → SIGSEGV — latent bug, logged) or gets + swallowed by the thread pool's own dispatcher. + +Test added: + +- **thread/042-thread_finally_non_callable** `$t->finally("not-a-fn")` + hits the `zend_is_callable` rejection at L2309-2311. The second + half of the original test (finally registration on a running + thread) crashed on teardown and was removed — the failing path is + a separate latent bug to investigate later. + +Remaining thread.c gaps: require fault injection, custom thread pool, +or fix of the pre-existing crash in the finally-handlers-on-running +path. + +### 2026-04-15 — fs_watcher.c (target #6) SKIPPED + +Target is the RENAME-on-existing-CHANGE coalesce merge branch at +fs_watcher.c L143-144. The plan was to trigger `file_put_contents` +followed by `rename()` on the same file within the coalesce window +so the libuv fs_event callback fires twice for the same key. On +Linux inotify, `rename()` emits `MOVED_FROM` / `MOVED_TO` with a +*different* filename than the preceding CHANGE, so the coalesce key +(dir+filename) doesn't match — a merged event never forms. Skipped; +would need per-platform shims or a libuv test harness. + +### 2026-04-15 — thread_pool.c (target #7) + +`thread_pool.c` 80.40% → **80.98%** (+0.58%, ~2 lines of 347). + +Test added: + +- **thread_pool/032-map_on_closed_pool** `$pool->map()` on a closed + pool throws `ThreadPoolException("ThreadPool is closed")`, covering + METHOD(map) L515-522. Existing test 007 already covered the same + guard for `submit()`. + +Remaining ~19% is the post-send channel-closed race (L480-592, both +submit and map paths), where `pool->base.closed` is still 0 but the +channel send fails. Reproducing that cleanly would need a second +thread closing the channel between the closed-check and the send +call — not worth a fuzzy sleep-based test. + +### 2026-04-15 — context.c (target #8) + +`context.c` 90.70% → **91.86%** (+1.16%, ~2 lines of 172). + +Test added: + +- **context/008-context_get_missing** `$ctx->get("missing")` returns + NULL, covering METHOD(get) L242 fallback. + +Remaining ~8% is the C-API-only "invalid key type" error branches +at L85/110/126/159 (the PHP methods catch bad types via +VALIDATE_CONTEXT_KEY before reaching the helpers) and the parent- +scope walker L63-65 which needs a 3+ level scope hierarchy with +contexts on each level — covered indirectly by context/007 but +the exact NULL-fallback line isn't hit. + +## End of phase 2 + +Aggregate `lcov` summary after phase 2: + +``` +lines......: 78.3% (9231 of 11785 lines) +functions..: 89.1% (808 of 907 functions) +``` + +Delta from phase 2 start: **+104 lines (+0.89%)**, **+10 functions +(+1.10%)** across 6 targets. Phase 2 conclusion: most of the §6 +"achievable" targets had smaller actual payoff than the report +estimated — the dominant cost in the remaining gap is defensive +branches behind already-covered guards, C-API helpers without +internal callers, and fault-injection paths. diff --git a/COVERAGE_REPORT.md b/COVERAGE_REPORT.md new file mode 100644 index 00000000..97753b74 --- /dev/null +++ b/COVERAGE_REPORT.md @@ -0,0 +1,377 @@ +# TrueAsync Coverage Report + +Session wrap-up for the gcov-driven test-writing pass against +`ext/async/`. Companion to `COVERAGE_PLAN.md`, which held the initial +baseline survey and the raw per-file gap tables. + +## 1. Headline numbers + +| Metric | Baseline | After P0 (curl/mysqli rebuild) | Final | Δ from baseline | +| --- | --- | --- | --- | --- | +| **Lines** | 74.30% (8756 / 11785) | 75.00% | **77.45% (9127 / 11785)** | **+3.15% (+371 lines)** | +| **Functions** | 85.30% (774 / 907) | 86.30% | ~88% | +2.7% | +| **Tests passing** | 804 | 908 | 920+ | +120 | + +Build: `./configure --enable-zts --enable-debug --disable-all --enable-async +--enable-pdo --with-pdo-mysql --with-pdo-sqlite --with-pdo-pgsql --with-curl +--with-openssl --with-mysqli=mysqlnd --enable-sockets --enable-posix +--enable-pcntl --enable-gcov` + +Working copy of the build with gcda: `/home/edmond/build-gcov-src/`. +HTML report: `build-gcov-src/coverage_html/index.html`. + +## 2. Per-file outcome + +| File | Baseline | Final | Δ | Note | +| --- | --- | --- | --- | --- | +| `scope.c` | 71.6% | **78.9%** | +7.3% | 13 tests — the largest topical batch | +| `exceptions.c` | 56.3% | **65.0%** | +8.7% | 2 tests + rest is API-only dead code | +| `pool.c` | 76.7% | **85.9%** | +9.2% | 4 tests — healthcheck + error paths | +| `async.c` | 76.5% | **84.5%** | +8.0% | 5 tests — Timeout/signal/shutdown | +| `thread.c` | 73.3% | **78.8%** | +5.5% | 1 test — status accessors | +| `task_group.c` | 81.5% | **84.0%** | +2.5% | 1 test — gc traversal | +| `context.c` | 87.8% | **90.7%** | +2.9% | 1 test — 3-level hierarchy | +| `libuv_reactor.c` | 62.3% | 62.7% | +0.4% | mostly UDP/diagnostic paths, see §5 | +| `thread_pool.c` | 80.4% | 80.4% | 0 | 1 test covered op_array paths but no line-count shift | +| `future.c` | 80.4% | 80.4% | 0 | still best-candidate for next pass | +| `coroutine.c` | 76.5% | 76.5% | 0 | C-API-only helpers dominate the gap | +| `scheduler.c` | 77.0% | 79.2% | +2.2% | inherited from scope/pool tests | + +Unchanged files (all ≥85% or too small to matter): +`iterator.c 85.3%`, `channel.c 86.7%`, `thread_channel.c 92.4%`, +`internal/allocator.c 100%`. + +## 3. Tests added (28 in 5 commits) + +Commits, in order, on branch `98-thread-pool`: + +1. **`9f56fb0` — scope (13 tests, 043–055)** + - 043 re-set `setExceptionHandler`/`setChildScopeExceptionHandler` + - 044 `awaitAfterCancellation` on a non-cancelled scope + - 045 self-deadlock in `awaitCompletion` + - 046 self-deadlock in `awaitAfterCancellation` + - 047 `setExceptionHandler` handler actually fires + - 048 `setChildScopeExceptionHandler` handler actually fires + - 049 `awaitAfterCancellation(errorHandler)` runs the handler + - 050 `awaitAfterCancellation()` without handler propagates + - 051 self-deadlock from a grandchild scope (recursive walker) + - 052 `gc_get` walks scope context values and object keys + - 053 destroy scope object with active coroutines + - 054 error handler may throw and its exception propagates + - 055 `finally()` on a disposed scope fires synchronously + +2. **`bafb9f6` — exceptions (2 tests, edge_cases/012–013)** + - 012 `awaitCompletion()` on a cancelled scope → `async_throw_cancellation` + - 013 direct `CompositeException::addException()` / `getExceptions()` + +3. **`9fb1f30` — pool (4 tests, 048–051)** + - 048 periodic healthcheck (`pool_call_healthcheck` + timer callback) + - 049 healthcheck callback throwing → resource treated as unhealthy + - 050 factory throwing during min-size prewarm + - 051 `beforeAcquire` rejection + destructor throwing aborts acquire + +4. **`cc5ea17` — thread / task_group / context (4 tests)** + - `task_group/035` gc_get handler walks tasks in PENDING/RUNNING/ERROR + - `thread/041` Thread status accessors + finally() fast-path + - `thread_pool/031` closure with try/catch/static/nested fns + - `context/007` three-level scope hierarchy context walker + +5. **`e065c89` — async.c surface area (5 tests)** + - `info/003` `phpinfo()` → `PHP_MINFO_FUNCTION(async)` + - `common/timeout_class_methods` Async\Timeout methods + factory + - `signal/004` `Async\signal()` fast path for resolved cancellation + - `edge_cases/014` explicit `Async\graceful_shutdown()` + - `iterate/type_error_invalid_argument` `Async\iterate` TypeError branch + +## 4. Bugs discovered via coverage testing + +Three real defects were surfaced by tests that were trying to land on +previously-unreached lines. **All three are now fixed** — see the per-bug +"Fix" paragraphs below. + +### 4.1 `Async\Scope::disposeAfterTimeout()` leaks the scope refcount — FIXED + +**Location:** `scope.c:675` +```c +callback->scope = scope_object->scope; +callback->scope->scope.event.ref_count++; // ← no paired decrement +``` + +The timer callback bumps the scope's `ref_count` once, but nothing in +either `scope_timeout_callback()` (L618–634) or +`scope_timeout_coroutine_entry()` (L601–616) releases it. Regardless of +whether the timer fires or the scope empties first, one reference is +always leaked. + +**Effect in DEBUG:** 4 consistent zend_mm leaks per invocation +(`scope.c:38`, `zend_string.h:166`, `zend_objects.c:190`, +`scope.c:1208`) — observable by hand but not currently caught by any +phpt. + +**Coverage impact:** blocks test 043 +(`043-scope_disposeAfterTimeout_with_active_coroutine`, removed after +discovery) and thereby ~52 lines in `scope.c:601-684` — the full +`disposeAfterTimeout` timer pathway. + +**Fix:** `scope.c` now installs a custom `dispose` handler on the +timeout callback (`scope_timeout_callback_dispose`) that releases the +scope ref when the callback is freed without having transferred +ownership. On the fire path, `scope_timeout_callback()` hands ownership +of the scope ref to the spawned cancellation coroutine by clearing +`scope_callback->scope` before disposing the timer, and +`scope_timeout_coroutine_entry()` calls `ZEND_ASYNC_EVENT_RELEASE` after +`SCOPE_CANCEL`. The raw `ref_count++` was replaced with the proper +`ZEND_ASYNC_EVENT_ADD_REF` macro, and the `add_callback` failure path +now releases the scope ref and frees the unclaimed callback (previously +leaked on that error path too). A full end-to-end test of the fire +path is still blocked by a separate, pre-existing leak in the +cancellation-coroutine teardown that reproduces identically without +this fix — out of scope for this change. + +### 4.2 `async_composite_exception_add_exception` writes to hard-coded slot 7 — FIXED + +**Location:** `exceptions.c:258` +```c +zval *exceptions_prop = &composite->properties_table[7]; +``` + +The helper assumes `exceptions` lives at `properties_table[7]`. With +the typed-property layout actually produced for +`Async\CompositeException extends \Exception { private array +$exceptions; }` this slot does not line up with the `exceptions` +property, so: + +- `getExceptions()` on an empty composite hits the "Typed property must + not be accessed before initialization" fatal. +- Multiple `addException()` calls clobber unrelated properties and + produce `var_dump` output with garbage pointer fields and + implausible string lengths. + +**Coverage impact:** test 013 had to be trimmed to a single-add +scenario; the multi-add iteration branch (~3 lines) is blocked. + +**Fix:** `exceptions.c` now reads and writes `$exceptions` through +`zend_read_property` / `zend_update_property` with the actual property +name, so the engine resolves the correct typed-property slot +regardless of inherited layout. `getExceptions()` switched from +`silent=0` to `silent=1` (`BP_VAR_IS`) so an empty composite reads +back as `[]` rather than triggering the typed-uninit fatal. A second +latent bug surfaced while verifying the fix: the PHP method +`addException` was passing `transfer=true` to the helper even though +`Z_PARAM_OBJECT_OF_CLASS` only lends a borrowed reference — with the +slot-7 corruption removed, this dangling-pointer path was exercised +for the first time and made repeated adds all alias to the +last-inserted object. Changed the method call site to `transfer=false` +so the helper performs the `GC_ADDREF`. Test 013 was expanded to +cover (a) empty-composite read, (b) three adds of different classes, +(c) class/message round-trip. + +### 4.3 `pool_strategy_report_failure` use-after-free — FIXED + +**Location:** `pool.c:348-355` +```c +zend_object *ex = zend_throw_exception(NULL, "Resource validation failed", 0); +zend_clear_exception(); +ZVAL_OBJ(&error_zval, ex); // ← ex is already freed here +``` + +`zend_throw_exception()` sets `EG(exception)` to a refcount-1 object. +`zend_clear_exception()` drops that reference, freeing the exception. +The subsequent `ZVAL_OBJ(&error_zval, ex)` captures a dangling +pointer, which is then handed to the userland `reportFailure()` handler. + +**Reproducer:** construct a pool with a strategy *and* a `beforeRelease` +callback that returns `false`, then acquire + release one resource. +`zend_mm_heap corrupted` + SIGSEGV at shutdown on a ZTS DEBUG build. +Minimal repro also exists (~15 lines of PHP). + +**Coverage impact:** blocks the entire +`pool_strategy_report_failure` function (~24 lines in `pool.c:322-364`) +plus indirect coverage of the strategy-failure wiring. + +**Fix:** `pool.c` constructs the generic exception directly via +`object_init_ex(zend_ce_exception)` + `zend_update_property_ex(MESSAGE)` +instead of routing it through `zend_throw_exception` + `zend_clear_exception`. +The exception no longer touches `EG(exception)`, so its lifetime is +entirely local — tracked by an `owns_error` flag and released with +`zval_ptr_dtor(&error_zval)` after the `reportFailure` call. New test +`tests/pool/052-pool_strategy_report_failure_before_release.phpt` +exercises the path via a pool with `beforeRelease: fn() => false` and +verifies the strategy receives a well-formed `Exception` instance. + +## 5. Dead zones — where coverage cannot realistically climb + +This is the important part. I walked every remaining uncovered region +(>3 contiguous lines) and classified it. The phpt-only ceiling is +~83–84 %. + +### 5.1 API exports with no internal callers (~220 lines, HARD DEAD) + +`PHP_ASYNC_API` functions exist for drivers/extensions that consume +TrueAsync, but nothing inside `ext/async` calls them. + +| File | Functions | Lines | +| --- | --- | --- | +| `exceptions.c` | `async_throw_timeout`, `async_throw_poll`, `async_throw_deadlock` (each has a 2-branch implementation) | ~66 | +| `zend_common.c` | `zend_exception_to_warning`, `zend_current_exception_get_{message,file,line}`, `zend_exception_merge`, `zend_new_weak_reference_from`, `zend_resolve_weak_reference`, `zend_hook_php_function`, `zend_replace_method`, `zend_get_function_name_by_fci` | 101 | +| `coroutine.c` | `async_coroutine_context_{set,get,has,delete}` C API | 50 | + +Coverable only by (a) linking a test-only C extension that calls each +entry point, or (b) deleting the dead code. + +### 5.2 Diagnostic `*_info()` describers (~160 lines, SOFT DEAD) + +These are the per-event-type string formatters that show up in the +deadlock report (`scheduler.c:dump_deadlock_info`). They are only +called when the scheduler actually detects a deadlock **and** no +zombie-resolution path can progress. + +| File | Functions | Lines | +| --- | --- | --- | +| `libuv_reactor.c` | `libuv_*_info` for poll/timer/signal/process/filesystem/dns/exec/io/listen/task/trigger | 108 (L185-310) | +| `scope.c` | `scope_info` | ~14 | +| `task_group.c` | `task_group_info` | ~6 | +| pool/future info | | ~20 | + +These *could* in theory be hit by a phpt that triggers a real deadlock +with `async.debug_deadlock=1`. In practice the scheduler's zombie +optimisation auto-completes most phpt-reachable deadlock setups before +the report is produced. A three-way cycle using a scope event kept +failing to deadlock for me because the scope completes as soon as its +only coroutine becomes a zombie waiting on a peer. Coverage-by-test +here is possible, but fragile. + +### 5.3 `replay()` event-callback entry points (~60 lines, HARD DEAD) + +`scope_replay` (L1039–1067), `task_group_replay`, etc. These are the +C-level mechanism used by extensions that want to attach a late +callback to an already-finished event. No userland surface. + +### 5.4 Standalone C tests for `circular_buffer` (~40 lines, HARD DEAD) + +`circular_buffer_new`, `circular_buffer_destroy`, +`circular_buffer_capacity`, `zval_circular_buffer_new` are only called +from `ext/async/internal/tests/circular_buffer_test.c`, a standalone C +test that has its own `CmakeLists.txt` and is not linked into PHP +itself. From a phpt perspective they are unreachable; the embedded +`circular_buffer_ctor` used by pool/channel is fully covered. + +### 5.5 Fake `scope_object` bridge (~14 lines, HARD DEAD) + +`scope.c:1506–1522` creates a temporary `async_scope_object_t` when an +exception is raised against a scope whose PHP object has already been +garbage-collected. Reaching it requires holding an internal-scope +reference past the PHP object destruction, which is not possible from +userland without a specifically crafted GC cycle that the PHP engine +will not actually produce. + +### 5.6 Pool C-API destruction paths (~50 lines, HARD DEAD) + +`pool.c:977–1099` — `zend_async_pool_destroy()` with +`ZEND_ASYNC_POOL_F_*_INTERNAL` flags, and +`async_pool_create_object_for_pool()` for embedded pool wrappers. Both +only run when a non-async-module driver (curl, pdo_mysql) owns the +pool. + +### 5.7 OS-error branches in libuv reactor (~150 lines, FAULT-INJECTION) + +Each `if (uv_*_init(...) < 0)` / `uv_*_open < 0` branch across +`libuv_reactor.c` (TCP/UDP/pipe/TTY init, fileno, bind, fsync start, +listen binding, etc.). Reachable in principle with bad file +descriptors or privileged ports, but fragile from phpt — most of them +need an unprivileged non-root environment and genuinely bad inputs. + +### 5.8 Bailout / critical-exception paths (~120 lines, HARD DEAD) + +`async.c:814–987` (partial), `scope.c:858-1121`, `scheduler.c:842-856`. +These run when `zend_bailout()` is invoked mid-operation (e.g., fatal +error during a coroutine dispose). phpt has no controlled way to +trigger a bailout that the engine will not then treat as a test +failure. + +### 5.9 Private `__construct` C guards (~6 lines, HARD DEAD) + +Both `Async\Thread::__construct` and `Async\Timeout::__construct` throw +"Cannot directly construct …". Because the stub declares them +`private`, PHP's visibility check fires first with a different error +and the C body is never reached. + +### 5.10 Defensive "should not happen" branches (~30–50 lines spread out) + +Scattered `ZEND_ASSERT`-style early returns and warnings that guard +internal state the runtime should never produce. These are not bugs, +they are belt-and-braces code. Most common in +`circular_buffer.c:414-420`, `future.c`, and `scheduler.c` finalisation +paths. + +## 6. Still achievable (next pass budget: ~150–200 lines) + +Everything below is reachable with more phpt effort. + +| Target | File | Approx lines | Notes | +| --- | --- | --- | --- | +| finally handlers chain | `future.c:1192–1252` | ~60 | biggest single achievable block | +| TaskGroup cancel/race/error paths | `task_group.c:243–1457` | ~40 across 5 spots | needs targeted tests for `race()`/`any()` with failed tasks | +| `Async\iterate` error paths | `async.c:859–987` | ~50 | iterator creation failures + cancel_pending merges | +| Thread internals | `thread.c:1957–2172` | ~40 | needs tests for `getResult`/`getException` on a still-running thread, etc. | +| Channel close/timeout | `channel.c:322–778` | ~40 | several close-race edge cases | +| fs_watcher coalesce RENAME+CHANGE | `fs_watcher.c:141–246` | ~20 | two events on same path within coalesce window | +| thread_pool submit-after-close race | `thread_pool.c:483–592` | ~15 | very tight race window | +| Context find-local error branch | `context.c:34–38, 126–132` | ~10 | mostly covered now | + +After landing the full achievable budget, realistic ceiling is **~80%**. + +## 7. Recommended next steps + +Ordered by ROI, highest first. + +1. **~~Fix the three bugs found in §4~~** — DONE. All three bugs are + fixed in the current branch. `pool_strategy_report_failure` and the + `composite_exception` slot/lifetime bugs have new phpt coverage + (tests/pool/052, tests/edge_cases/013). The `disposeAfterTimeout` + ref-leak fix still lacks a dedicated regression test because the + timer-fires path reproduces a separate pre-existing leak identical + with and without the fix — see 4.1 note. + +2. **Delete the mostly-dead C API exports in §5.1** (or wire them into + an `ext/async/tests/capi_probe/` test extension). Deleting is + cheaper: `async_throw_timeout/poll/deadlock` genuinely have no + internal callers, and most of `zend_common.c` is the same. + +3. **Write the achievable batch in §6** — targets in `future.c`, + `task_group.c`, `async.c` iterate, `thread.c`, `channel.c`. This is + the last chunk that pays off with phpt-only work. + +4. **Consider teaching `make test` to run + `ext/async/internal/tests/circular_buffer_test.c`** — it already + exists as a standalone C test with full coverage of the `_new` / + `_destroy` / `_capacity` API. Linking it into the PHP test harness + would close ~40 lines in `circular_buffer.c` for free. + +5. Everything beyond that requires fault injection, a deadlock-debug + phpt framework, or tolerating bailout behaviour in run-tests. Not + worth the engineering churn unless a specific customer bug pushes + us there. + +## 8. How to reproduce the measurement + +```bash +cd /home/edmond/build-gcov-src + +# Reset coverage counters +find . -name '*.gcda' -delete + +# Run the full async suite +MYSQL_TEST_USER=test MYSQL_TEST_PASSWD=test \ +MYSQL_TEST_SOCKET=/var/run/mysqld/mysqld.sock MYSQL_TEST_HOST=localhost \ +make test TESTS='ext/async/tests' + +# Capture + summary +lcov --capture --directory ext/async --output-file coverage_async.info --no-external +lcov --summary coverage_async.info + +# HTML +rm -rf coverage_html +genhtml coverage_async.info --output-directory coverage_html \ + --prefix /home/edmond/build-gcov-src +``` diff --git a/async.c b/async.c index a21aa7d9..5cfd4f60 100644 --- a/async.c +++ b/async.c @@ -16,6 +16,7 @@ #include "zend_exceptions.h" #include "zend_closures.h" +#include "zend_common.h" #ifdef HAVE_CONFIG_H #include #endif @@ -28,6 +29,8 @@ #include "context.h" #include "future.h" #include "channel.h" +#include "thread_channel.h" +#include "thread_pool.h" #include "pool.h" #include "task_group.h" #include "fs_watcher.h" @@ -37,6 +40,7 @@ #include "async_arginfo.h" #include "zend_interfaces.h" #include "libuv_reactor.h" +#include "thread.h" zend_class_entry *async_ce_awaitable = NULL; zend_class_entry *async_ce_completable = NULL; @@ -141,6 +145,73 @@ PHP_FUNCTION(Async_spawn_with) RETURN_OBJ_COPY(&coroutine->std); } +PHP_FUNCTION(Async_spawn_thread) +{ + THROW_IF_ASYNC_OFF; + THROW_IF_SCHEDULER_CONTEXT; + + zval *entry_zv = NULL; + bool inherit = true; + zval *bootloader_zv = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 3) + Z_PARAM_OBJECT_OF_CLASS(entry_zv, zend_ce_closure) + Z_PARAM_OPTIONAL + Z_PARAM_BOOL(inherit) + Z_PARAM_OBJECT_OF_CLASS_OR_NULL(bootloader_zv, zend_ce_closure) + ZEND_PARSE_PARAMETERS_END(); + + SCHEDULER_LAUNCH; + + const uint32_t thread_flags = inherit ? ZEND_THREAD_F_INHERIT : 0; + + /* Build fcall structs from Closure zvals */ + zend_fcall_t entry; + zend_fcall_info_init(entry_zv, 0, &entry.fci, &entry.fci_cache, NULL, NULL); + + zend_fcall_t boot, *boot_ptr = NULL; + if (bootloader_zv != NULL + && zend_fcall_info_init(bootloader_zv, 0, &boot.fci, &boot.fci_cache, NULL, NULL) == SUCCESS) { + boot_ptr = &boot; + } + + zend_async_thread_event_t *thread_event = + ZEND_ASYNC_NEW_THREAD_EVENT_EX(&entry, boot_ptr, thread_flags, 0); + + if (UNEXPECTED(thread_event == NULL)) { + RETURN_THROWS(); + } + + /* Create the Thread PHP object — takes ownership of the event (ref_count=1) */ + zend_object *obj = async_ce_thread->create_object(async_ce_thread); + async_thread_object_t *thread_obj = async_thread_object_from_obj(obj); + thread_obj->thread_event = thread_event; + + /* Capture the current scope as the parent for any finally-handlers that + * may get registered later. Addref keeps the scope struct alive until + * thread_object_free() releases it, so handlers always have a valid + * parent even if the user drops their scope reference first. */ + thread_obj->parent_scope = ZEND_ASYNC_CURRENT_SCOPE; + if (thread_obj->parent_scope != NULL) { + ZEND_ASYNC_EVENT_ADD_REF(&thread_obj->parent_scope->event); + } + + /* Set event reference so ZEND_ASYNC_OBJECT_TO_EVENT() can resolve from this object */ + ZEND_ASYNC_EVENT_REF_SET(thread_obj, + XtOffsetOf(async_thread_object_t, std), &thread_event->base); + + /* Record spawn location */ + zend_apply_current_filename_and_line(&thread_event->filename, &thread_event->lineno); + + /* Start the thread */ + if (UNEXPECTED(!thread_event->base.start(&thread_event->base))) { + OBJ_RELEASE(obj); + RETURN_THROWS(); + } + + RETURN_OBJ(obj); +} + PHP_FUNCTION(Async_suspend) { ZEND_PARSE_PARAMETERS_NONE(); @@ -1229,6 +1300,14 @@ PHP_METHOD(Async_Timeout, cancel) if (timeout->event != NULL) { zend_async_timer_event_t *timer_event = timeout->event; + /* Mirror async_timeout_destroy_object(): clear timeout_ext->std before + * dispose() so async_timeout_event_dispose() does NOT OBJ_RELEASE the + * object. In the current architecture the event holds only a raw + * pointer, not a counted reference, so releasing here would drop the + * caller's live ref and leave userland $t dangling — tripping + * IS_OBJ_VALID at shutdown. */ + async_timeout_ext_t *timeout_ext = ASYNC_TIMEOUT_FROM_EVENT(&timer_event->base); + timeout_ext->std = NULL; timeout->event = NULL; timer_event->base.dispose(&timer_event->base); } @@ -1445,6 +1524,8 @@ ZEND_MINIT_FUNCTION(async) async_register_context_ce(); async_register_exceptions_ce(); async_register_channel_ce(); + async_register_thread_channel_ce(); + async_register_thread_pool_ce(); async_register_fs_watcher_ce(); async_register_circuit_breaker_ce(); async_ce_signal = register_class_Async_Signal(); @@ -1452,6 +1533,7 @@ ZEND_MINIT_FUNCTION(async) async_register_task_group_ce(); async_register_task_set_ce(); async_register_future_ce(); + async_register_thread_ce(); async_scheduler_startup(); async_api_register(); diff --git a/async.stub.php b/async.stub.php index 7fd06ec8..9fcf8cef 100644 --- a/async.stub.php +++ b/async.stub.php @@ -102,6 +102,16 @@ function get_coroutines(): array {} */ function iterate(iterable $iterable, callable $callback, int $concurrency = 0, bool $cancelPending = true): void {} +/** + * Spawn a new OS thread that runs the given closure. + * + * @param \Closure $task The closure to execute in the new thread. + * @param bool $inherit If true (default), inherit parent's function/class tables. + * @param \Closure|null $bootloader Optional closure executed in the thread before $task. + * @return Thread A thread handle that implements Completable. + */ +function spawn_thread(\Closure $task, bool $inherit = true, ?\Closure $bootloader = null): Thread {} + /** * Start the graceful shutdown of the Scheduler. */ diff --git a/async_API.c b/async_API.c index 55f17d80..829aa0b4 100644 --- a/async_API.c +++ b/async_API.c @@ -25,6 +25,7 @@ #include "scheduler.h" #include "scope.h" #include "task_group.h" +#include "thread.h" #include "zend_common.h" zend_async_scope_t *async_provide_scope(zend_object *scope_provider) @@ -278,6 +279,10 @@ static zend_class_entry *async_get_class_ce(zend_async_class type) return async_ce_service_unavailable_exception; case ZEND_ASYNC_EXCEPTION_OPERATION_CANCELLED: return async_ce_operation_cancelled_exception; + case ZEND_ASYNC_EXCEPTION_THREAD_TRANSFER: + return async_ce_thread_transfer_exception; + case ZEND_ASYNC_EXCEPTION_REMOTE: + return async_ce_remote_exception; default: return NULL; } @@ -1278,5 +1283,9 @@ void async_api_register(void) async_new_future_obj, async_new_channel_obj_stub, async_new_group, - engine_shutdown); + engine_shutdown, + async_thread_snapshot_create_api, + async_thread_snapshot_destroy_api, + async_thread_run, + async_thread_load_result); } \ No newline at end of file diff --git a/async_arginfo.h b/async_arginfo.h index ffe77b97..15672ca6 100644 --- a/async_arginfo.h +++ b/async_arginfo.h @@ -12,6 +12,12 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_Async_spawn_with, 0, 2, Async\\Co ZEND_ARG_VARIADIC_TYPE_INFO(0, args, IS_MIXED, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_Async_spawn_thread, 0, 1, Async\\Thread, 0) + ZEND_ARG_OBJ_INFO(0, task, Closure, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, inherit, _IS_BOOL, 0, "true") + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, bootloader, Closure, 1, "null") +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_Async_suspend, 0, 0, IS_VOID, 0) ZEND_END_ARG_INFO() @@ -136,6 +142,7 @@ ZEND_END_ARG_INFO() ZEND_FUNCTION(Async_spawn); ZEND_FUNCTION(Async_spawn_with); +ZEND_FUNCTION(Async_spawn_thread); ZEND_FUNCTION(Async_suspend); ZEND_FUNCTION(Async_protect); ZEND_FUNCTION(Async_await); @@ -163,6 +170,7 @@ ZEND_METHOD(Async_Timeout, isCancelled); static const zend_function_entry ext_functions[] = { ZEND_RAW_FENTRY(ZEND_NS_NAME("Async", "spawn"), zif_Async_spawn, arginfo_Async_spawn, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("Async", "spawn_with"), zif_Async_spawn_with, arginfo_Async_spawn_with, 0, NULL, NULL) + ZEND_RAW_FENTRY(ZEND_NS_NAME("Async", "spawn_thread"), zif_Async_spawn_thread, arginfo_Async_spawn_thread, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("Async", "suspend"), zif_Async_suspend, arginfo_Async_suspend, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("Async", "protect"), zif_Async_protect, arginfo_Async_protect, 0, NULL, NULL) ZEND_RAW_FENTRY(ZEND_NS_NAME("Async", "await"), zif_Async_await, arginfo_Async_await, 0, NULL, NULL) diff --git a/config.m4 b/config.m4 index bda22036..d0a24fb7 100644 --- a/config.m4 +++ b/config.m4 @@ -12,13 +12,13 @@ if test "$PHP_ASYNC" = "yes"; then PHP_NEW_EXTENSION([async], [async.c coroutine.c scope.c scheduler.c exceptions.c iterator.c async_API.c \ context.c libuv_reactor.c future.c channel.c pool.c task_group.c fs_watcher.c \ - internal/allocator.c internal/circular_buffer.c \ + thread.c thread_channel.c thread_pool.c internal/allocator.c internal/circular_buffer.c \ zend_common.c], $ext_shared) dnl Optionally install headers (if desired for public use). PHP_INSTALL_HEADERS([ext/async], - [php_async.h coroutine.h scope.h scheduler.h exceptions.h iterator.h async_API.h context.h future.h channel.h pool.h task_group.h fs_watcher.h]) + [php_async.h coroutine.h scope.h scheduler.h exceptions.h iterator.h async_API.h context.h future.h channel.h thread_channel.h thread_pool.h pool.h task_group.h fs_watcher.h thread.h]) AC_PATH_PROG(PKG_CONFIG, pkg-config, no) diff --git a/config.w32 b/config.w32 index 09dba006..60272863 100644 --- a/config.w32 +++ b/config.w32 @@ -4,7 +4,7 @@ ARG_ENABLE('async', 'Enable True Async', 'no'); if (PHP_ASYNC == "yes") { - EXTENSION("async", "async.c coroutine.c scope.c scheduler.c exceptions.c iterator.c async_API.c zend_common.c context.c libuv_reactor.c future.c channel.c pool.c task_group.c fs_watcher.c"); + EXTENSION("async", "async.c coroutine.c scope.c scheduler.c exceptions.c iterator.c async_API.c zend_common.c context.c libuv_reactor.c future.c channel.c pool.c task_group.c fs_watcher.c thread.c thread_channel.c thread_pool.c"); ADD_SOURCES("ext/async/internal", "allocator.c circular_buffer.c"); ADD_FLAG("CFLAGS", "/D PHP_ASYNC"); @@ -24,6 +24,9 @@ if (PHP_ASYNC == "yes") { PHP_INSTALL_HEADERS("ext/async", "pool.h"); PHP_INSTALL_HEADERS("ext/async", "task_group.h"); PHP_INSTALL_HEADERS("ext/async", "fs_watcher.h"); + PHP_INSTALL_HEADERS("ext/async", "thread.h"); + PHP_INSTALL_HEADERS("ext/async", "thread_channel.h"); + PHP_INSTALL_HEADERS("ext/async", "thread_pool.h"); if (CHECK_HEADER_ADD_INCLUDE("libuv/uv.h", "CFLAGS_UV", PHP_PHP_BUILD + "\\include") && CHECK_LIB("libuv.lib", "libuv")) { diff --git a/coroutine.c b/coroutine.c index 9f726d71..2c3187d6 100644 --- a/coroutine.c +++ b/coroutine.c @@ -812,6 +812,7 @@ bool async_coroutine_resume(zend_coroutine_t *coroutine, zend_object *error, con // we will execute it immediately! if (UNEXPECTED(in_scheduler_context && coroutine == ZEND_ASYNC_CURRENT_COROUTINE)) { waker->status = ZEND_ASYNC_WAKER_RESULT; + ZEND_ASYNC_WAKER_CLEAN_EVENTS(coroutine->waker); return true; } diff --git a/exceptions.c b/exceptions.c index 4d08d71c..68c75135 100644 --- a/exceptions.c +++ b/exceptions.c @@ -43,7 +43,9 @@ PHP_METHOD(Async_CompositeException, addException) ZEND_PARSE_PARAMETERS_END(); zval *object = ZEND_THIS; - async_composite_exception_add_exception(Z_OBJ_P(object), Z_OBJ_P(exception), true); + /* The PHP parameter binding lends us a borrowed reference — the caller + * still owns it — so pass transfer=false and let the helper ADDREF. */ + async_composite_exception_add_exception(Z_OBJ_P(object), Z_OBJ_P(exception), false); } PHP_METHOD(Async_CompositeException, getExceptions) @@ -51,10 +53,12 @@ PHP_METHOD(Async_CompositeException, getExceptions) ZEND_PARSE_PARAMETERS_NONE(); zval *object = ZEND_THIS; + /* silent=1 so an uninitialised typed property returns UNDEF instead of + * throwing — an empty composite should read back as `[]`, not a fatal. */ zval *exceptions_prop = zend_read_property( - async_ce_composite_exception, Z_OBJ_P(object), "exceptions", sizeof("exceptions") - 1, 0, NULL); + async_ce_composite_exception, Z_OBJ_P(object), "exceptions", sizeof("exceptions") - 1, 1, NULL); - if (Z_TYPE_P(exceptions_prop) == IS_ARRAY) { + if (exceptions_prop != NULL && Z_TYPE_P(exceptions_prop) == IS_ARRAY) { RETURN_ZVAL(exceptions_prop, 1, 0); } else { array_init(return_value); @@ -255,10 +259,19 @@ async_composite_exception_add_exception(zend_object *composite, zend_object *exc return; } - zval *exceptions_prop = &composite->properties_table[7]; + /* Read the typed `$exceptions` property through the proper property API. + * Using silent=1 (BP_VAR_IS) returns UNDEF for an uninitialised typed + * property instead of throwing "must not be accessed before initialisation". */ + zval *exceptions_prop = zend_read_property( + async_ce_composite_exception, composite, + "exceptions", sizeof("exceptions") - 1, 1, NULL); + + zval fresh_array; + bool needs_update = (exceptions_prop == NULL || Z_TYPE_P(exceptions_prop) != IS_ARRAY); - if (Z_TYPE_P(exceptions_prop) == IS_UNDEF) { - array_init(exceptions_prop); + if (needs_update) { + array_init(&fresh_array); + exceptions_prop = &fresh_array; } zval exception_zval; @@ -269,9 +282,24 @@ async_composite_exception_add_exception(zend_object *composite, zend_object *exc if (transfer) { OBJ_RELEASE(exception); } - } else if (false == transfer) { + if (needs_update) { + zval_ptr_dtor(&fresh_array); + } + return; + } + + if (false == transfer) { GC_ADDREF(exception); } + + if (needs_update) { + /* First write into the slot: go through update_property so the typed + * array property is initialised (and type-checked). Subsequent adds + * land directly in the stored array via the zend_read_property above. */ + zend_update_property(async_ce_composite_exception, composite, + "exceptions", sizeof("exceptions") - 1, &fresh_array); + zval_ptr_dtor(&fresh_array); + } } static void exception_coroutine_dispose(zend_coroutine_t *coroutine) diff --git a/future.c b/future.c index 52ef9a31..27ff7974 100644 --- a/future.c +++ b/future.c @@ -25,6 +25,7 @@ #include "scheduler.h" #include "iterator.h" #include "zend_smart_str.h" +#include "thread.h" /////////////////////////////////////////////////////////// /// Architecture Overview @@ -130,6 +131,7 @@ typedef struct { zend_async_event_callback_t base; async_future_t *future_obj; + zend_async_scope_t *scope; /* Scope captured at map()/catch()/finally() call time */ } async_future_callback_t; /** @@ -685,6 +687,7 @@ static zend_object *async_future_state_object_create(zend_class_entry *ce) ZEND_ASYNC_EVENT_REF_SET(state, XtOffsetOf(async_future_state_t, std), event); ZEND_ASYNC_EVENT_SET_ZVAL_RESULT(state->event); + state->shared_state = NULL; zend_object_std_init(&state->std, ce); object_properties_init(&state->std, ce); @@ -696,6 +699,26 @@ static void async_future_state_object_free(zend_object *object) { async_future_state_t *state = ASYNC_FUTURE_STATE_FROM_OBJ(object); + if (state->shared_state != NULL) { + /* Source side: if ownership was transferred but never completed, + * take the mutex to safely stop the trigger. */ + if (state->event != NULL && state->shared_state->trigger != NULL) { + if (!zend_atomic_int_load(&state->shared_state->completed)) { + ASYNC_MUTEX_LOCK(state->shared_state->mutex); + + if (!zend_atomic_int_load(&state->shared_state->completed)) { + zend_atomic_int_store(&state->shared_state->completed, 1); + state->shared_state->trigger->base.stop(&state->shared_state->trigger->base); + } + + ASYNC_MUTEX_UNLOCK(state->shared_state->mutex); + } + } + + async_future_shared_state_delref(state->shared_state); + state->shared_state = NULL; + } + zend_future_t *future = (zend_future_t *) state->event; state->event = NULL; @@ -706,6 +729,73 @@ static void async_future_state_object_free(zend_object *object) zend_object_std_dtor(&state->std); } +/** + * @brief Transfer handler for FutureState — transfers write ownership to another thread. + * + * ZEND_OBJECT_TRANSFER (source thread): + * Creates shared_state, binds trigger to original future, marks ownership transferred. + * + * ZEND_OBJECT_LOAD (destination thread): + * Creates new FutureState with shared_state — complete()/error() write through it. + */ +/* Direct offset — safe for pemalloc objects where handlers may be invalid */ +#define FUTURE_STATE_FROM_OBJ(obj) \ + ((async_future_state_t *)((char *)(obj) - XtOffsetOf(async_future_state_t, std))) + +static zend_object *async_future_state_transfer_obj( + zend_object *object, zend_async_thread_transfer_ctx_t *ctx, + zend_object_transfer_kind_t kind, zend_object_transfer_default_fn default_fn) +{ + if (kind == ZEND_OBJECT_TRANSFER) { + /* Source thread: create shared_state, bind to original future */ + async_future_state_t *src = FUTURE_STATE_FROM_OBJ(object); + + if (src->shared_state != NULL) { + zend_throw_exception(NULL, + "FutureState cannot be transferred to multiple threads", 0); + return NULL; + } + + zend_future_shared_state_t *shared = async_future_shared_state_create(); + async_future_shared_state_bind(shared, (zend_future_t *) src->event); + + /* Mark original future as used — it will be completed via trigger */ + ZEND_FUTURE_SET_USED((zend_future_t *) src->event); + + /* Source FutureState holds +1 ref (ownership transferred flag) */ + async_future_shared_state_addref(shared); + src->shared_state = shared; + + /* Deep copy to persistent memory — carries pointer for LOAD phase. + * No addref: persistent copy is temporary, LOAD phase does addref. */ + zend_object *dst = default_fn(object, ctx, sizeof(async_future_state_t)); + async_future_state_t *dst_state = FUTURE_STATE_FROM_OBJ(dst); + dst_state->shared_state = shared; + dst_state->event = NULL; + + return dst; + } else { + /* Destination thread: create emalloc FutureState with shared_state. + * Takes the initial ref (ref=1) from create — no extra addref. */ + zend_object *dst = default_fn(object, ctx, 0); + async_future_state_t *dst_state = FUTURE_STATE_FROM_OBJ(dst); + + async_future_state_t *src_state = FUTURE_STATE_FROM_OBJ(object); + dst_state->shared_state = src_state->shared_state; + async_future_shared_state_addref(dst_state->shared_state); + + /* default_fn called create_object which allocated a local future — + * mark as ignored (suppress "unused" warning) and release it */ + if (dst_state->event != NULL) { + ZEND_FUTURE_SET_IGNORED((zend_future_t *) dst_state->event); + ZEND_ASYNC_EVENT_RELEASE(dst_state->event); + dst_state->event = NULL; + } + + return dst; + } +} + /////////////////////////////////////////////////////////// /// Future object lifecycle /////////////////////////////////////////////////////////// @@ -797,6 +887,23 @@ FUTURE_STATE_METHOD(complete) const async_future_state_t *state = THIS_FUTURE_STATE; + /* Ownership was transferred to another thread */ + if (state->shared_state != NULL && state->event != NULL) { + async_throw_error("FutureState ownership was transferred to another thread"); + RETURN_THROWS(); + } + + /* Remote path: complete via shared state (cross-thread) */ + if (state->shared_state != NULL) { + if (zend_atomic_int_load(&state->shared_state->completed)) { + async_throw_error("FutureState is already completed"); + RETURN_THROWS(); + } + async_future_shared_state_complete(state->shared_state, result); + return; + } + + /* Local path */ if (state->event == NULL) { async_throw_error("FutureState is already destroyed"); RETURN_THROWS(); @@ -829,6 +936,23 @@ FUTURE_STATE_METHOD(error) const async_future_state_t *state = THIS_FUTURE_STATE; + /* Ownership was transferred to another thread */ + if (state->shared_state != NULL && state->event != NULL) { + async_throw_error("FutureState ownership was transferred to another thread"); + RETURN_THROWS(); + } + + /* Remote path: reject via shared state (cross-thread) */ + if (state->shared_state != NULL) { + if (zend_atomic_int_load(&state->shared_state->completed)) { + async_throw_error("FutureState is already completed"); + RETURN_THROWS(); + } + async_future_shared_state_reject(state->shared_state, Z_OBJ_P(throwable)); + return; + } + + /* Local path */ if (state->event == NULL) { async_throw_error("FutureState is already destroyed"); RETURN_THROWS(); @@ -856,6 +980,12 @@ FUTURE_STATE_METHOD(isCompleted) ZEND_PARSE_PARAMETERS_NONE(); const async_future_state_t *state = THIS_FUTURE_STATE; + + /* Remote path: check shared state atomic flag */ + if (state->shared_state != NULL && state->event == NULL) { + RETURN_BOOL(zend_atomic_int_load(&state->shared_state->completed)); + } + const zend_future_t *future = (zend_future_t *) state->event; if (UNEXPECTED(future == NULL)) { @@ -1426,11 +1556,14 @@ static void async_future_callback_handler(zend_async_event_t *event, } // Create async_iterator with our zend_object_iterator + // Use the scope captured at map()/catch()/finally() time, not the current scope. + // This is critical for remote futures: the trigger callback fires in the + // scheduler context, but the mapper coroutine must run in the subscriber's scope. async_iterator_t *async_iter = async_iterator_new(NULL, &new_iterator->it, NULL, future_mappers_handler, - ZEND_ASYNC_CURRENT_SCOPE, + future_callback->scope, 0, /* concurrency: default */ ZEND_COROUTINE_NORMAL, /* priority: default */ 0 /* iterator size: default */ @@ -1580,6 +1713,7 @@ static void async_future_create_mapper(INTERNAL_FUNCTION_PARAMETERS, async_futur callback->base.callback = async_future_callback_handler; callback->base.dispose = async_future_callback_dispose; callback->future_obj = source; + callback->scope = ZEND_ASYNC_CURRENT_SCOPE; // We do not increment the object's reference count because this is a "weak reference". // No GC_ADDREF(&source->std); @@ -1780,6 +1914,355 @@ zend_object *async_new_future_obj(zend_future_t *future) return &future_obj->std; } +/////////////////////////////////////////////////////////// +/// Shared future state — cross-thread future bridge +/////////////////////////////////////////////////////////// + +/** + * @brief Extended callback that carries a pointer to the shared state. + * + * Used for both the trigger callback (destination thread) and the source + * callback (source thread). The @c base field must be first so the struct + * can be cast from @c zend_async_event_callback_t*. + */ +typedef struct { + zend_async_event_callback_t base; + zend_future_shared_state_t *state; +} shared_state_cb_t; + +/** @see async_future_shared_state_delref — called when ref_count reaches 0. */ +void async_future_shared_state_destroy(zend_future_shared_state_t *state) +{ + if (!Z_ISUNDEF(state->transferred_result)) { + async_thread_release_transferred_zval(&state->transferred_result); + } + + if (!Z_ISUNDEF(state->transferred_exception)) { + async_thread_release_transferred_zval(&state->transferred_exception); + } + + if (state->trigger != NULL) { + state->trigger->base.dispose(&state->trigger->base); + state->trigger = NULL; + } + + ASYNC_MUTEX_DESTROY(state->mutex); + pefree(state, 1); +} + +/** + * @brief Trigger callback — runs in the destination thread when the source + * thread completes the shared state. + * + * Loads the transferred result or exception from persistent memory into + * the destination thread's emalloc heap, then resolves the target future. + */ +static void shared_state_trigger_cb(zend_async_event_t *event, + zend_async_event_callback_t *callback, void *result, zend_object *error) +{ + const shared_state_cb_t *cb = (const shared_state_cb_t *) callback; + zend_future_shared_state_t *state = cb->state; + zend_future_t *future = state->target_future; + + if (!Z_ISUNDEF(state->transferred_exception)) { + zval exc_zval; + async_thread_load_zval(&exc_zval, &state->transferred_exception); + async_thread_release_transferred_zval(&state->transferred_exception); + ZVAL_UNDEF(&state->transferred_exception); + + ZEND_FUTURE_REJECT(future, Z_OBJ(exc_zval)); + zval_ptr_dtor(&exc_zval); + } else { + zval loaded; + async_thread_load_zval(&loaded, &state->transferred_result); + async_thread_release_transferred_zval(&state->transferred_result); + ZVAL_UNDEF(&state->transferred_result); + + ZEND_FUTURE_COMPLETE(future, &loaded); + zval_ptr_dtor(&loaded); + } + + /* Deactivate trigger — result delivered, no longer needed in event loop */ + state->trigger->base.stop(&state->trigger->base); +} + +/** + * @brief Dispose handler for the trigger callback. + * + * Does NOT delref shared_state — trigger is owned by shared_state directly, + * so shared_state outlives the trigger. No circular reference. + */ +static void shared_state_trigger_cb_dispose(zend_async_event_callback_t *callback, zend_async_event_t *event) +{ + pefree(callback, 1); +} + +/** + * @brief Source callback — runs in the source thread when the source future + * completes. + * + * Transfers the source future's result or exception into the shared state + * (persistent memory) and fires the trigger to wake the destination thread. + */ +static void shared_state_source_cb(zend_async_event_t *event, + zend_async_event_callback_t *callback, void *result, zend_object *exception) +{ + const shared_state_cb_t *cb = (const shared_state_cb_t *) callback; + zend_future_shared_state_t *state = cb->state; + zend_future_t *source = (zend_future_t *) event; + + if (source->exception != NULL) { + async_future_shared_state_reject(state, source->exception); + } else { + async_future_shared_state_complete(state, &source->result); + } +} + +/** + * @brief Dispose handler for the source callback. + * + * Releases the shared state reference and frees the callback (emalloc — + * source callback lives in the source thread's heap). + */ +static void shared_state_source_cb_dispose(zend_async_event_callback_t *callback, zend_async_event_t *event) +{ + shared_state_cb_t *cb = (shared_state_cb_t *) callback; + async_future_shared_state_delref(cb->state); + efree(cb); +} + +/** @copydoc async_future_shared_state_create */ +zend_future_shared_state_t *async_future_shared_state_create(void) +{ + zend_future_shared_state_t *state = pecalloc(1, sizeof(zend_future_shared_state_t), 1); + + ZVAL_UNDEF(&state->transferred_result); + ZVAL_UNDEF(&state->transferred_exception); + ZEND_ATOMIC_INT_INIT(&state->completed, 0); + ZEND_ATOMIC_INT_INIT(&state->ref_count, 0); + ASYNC_MUTEX_INIT(state->mutex); + state->trigger = NULL; + state->target_future = NULL; + + return state; +} + +/** @copydoc async_future_shared_state_bind */ +bool async_future_shared_state_bind(zend_future_shared_state_t *state, zend_future_t *target_future) +{ + state->target_future = target_future; + + /* Create trigger on current thread's event loop */ + state->trigger = ZEND_ASYNC_NEW_TRIGGER_EVENT(); + if (UNEXPECTED(state->trigger == NULL)) { + return false; + } + + /* Subscribe trigger callback — does NOT hold ref to shared state + * (trigger is owned by shared_state, no circular reference) */ + shared_state_cb_t *trigger_cb = pecalloc(1, sizeof(shared_state_cb_t), 1); + trigger_cb->base.callback = shared_state_trigger_cb; + trigger_cb->base.dispose = shared_state_trigger_cb_dispose; + trigger_cb->base.ref_count = 1; + trigger_cb->state = state; + + state->trigger->base.add_callback(&state->trigger->base, &trigger_cb->base); + + /* Activate trigger immediately — we're expecting a result from another thread, + * so the event loop must stay alive until the trigger fires. */ + state->trigger->base.start(&state->trigger->base); + + return true; +} + +/** @copydoc async_future_shared_state_complete */ +void async_future_shared_state_complete(zend_future_shared_state_t *state, zval *result) +{ + /* Fast path: already completed */ + if (zend_atomic_int_load(&state->completed)) { + return; + } + + ASYNC_MUTEX_LOCK(state->mutex); + + if (zend_atomic_int_load(&state->completed)) { + ASYNC_MUTEX_UNLOCK(state->mutex); + return; + } + + zend_atomic_int_store(&state->completed, 1); + async_thread_transfer_zval(&state->transferred_result, result); + state->trigger->trigger(state->trigger); + + ASYNC_MUTEX_UNLOCK(state->mutex); +} + +/** @copydoc async_future_shared_state_reject */ +void async_future_shared_state_reject(zend_future_shared_state_t *state, zend_object *exception) +{ + /* Fast path: already completed */ + if (zend_atomic_int_load(&state->completed)) { + return; + } + + ASYNC_MUTEX_LOCK(state->mutex); + + if (zend_atomic_int_load(&state->completed)) { + ASYNC_MUTEX_UNLOCK(state->mutex); + return; + } + + zend_atomic_int_store(&state->completed, 1); + + zval exc_zval; + ZVAL_OBJ_COPY(&exc_zval, exception); + async_thread_transfer_zval(&state->transferred_exception, &exc_zval); + zval_ptr_dtor(&exc_zval); + + state->trigger->trigger(state->trigger); + + ASYNC_MUTEX_UNLOCK(state->mutex); +} + +/** @copydoc async_future_shared_state_source_cb */ + +zend_async_event_callback_t *async_future_shared_state_source_cb(zend_future_shared_state_t *state) +{ + async_future_shared_state_addref(state); + + shared_state_cb_t *cb = ecalloc(1, sizeof(shared_state_cb_t)); + cb->base.callback = shared_state_source_cb; + cb->base.dispose = shared_state_source_cb_dispose; + cb->base.ref_count = 1; + cb->state = state; + + return &cb->base; +} + +/////////////////////////////////////////////////////////// +/// Remote future — local future bound to a shared state +/////////////////////////////////////////////////////////// + +/** + * @brief Start handler for remote future. + * + * Proxies to the trigger's start to activate the uv_async handle + * in the event loop, so it keeps the loop alive while awaiting. + */ +static bool remote_future_start(zend_async_event_t *event) +{ + zend_future_remote_t *remote = (zend_future_remote_t *) event; + + if (remote->state->trigger != NULL) { + return remote->state->trigger->base.start(&remote->state->trigger->base); + } + + return true; +} + +/** + * @brief Stop handler for remote future. + * + * Proxies to the trigger's stop to deactivate the uv_async handle. + */ +static bool remote_future_stop(zend_async_event_t *event) +{ + zend_future_remote_t *remote = (zend_future_remote_t *) event; + + if (remote->state->trigger != NULL) { + return remote->state->trigger->base.stop(&remote->state->trigger->base); + } + + return true; +} + +/** + * @brief Dispose handler for remote future. + * + * Disposes the trigger, releases the shared state reference, + * cleans up the base future, and frees the remote future. + */ +static bool remote_future_dispose(zend_async_event_t *event) +{ + zend_future_remote_t *remote = (zend_future_remote_t *) event; + zend_future_t *future = &remote->future; + + if (remote->state != NULL) { + if (remote->state->trigger != NULL) { + remote->state->trigger->base.dispose(&remote->state->trigger->base); + remote->state->trigger = NULL; + } + async_future_shared_state_delref(remote->state); + remote->state = NULL; + } + + zval_ptr_dtor(&future->result); + + if (future->exception != NULL) { + OBJ_RELEASE(future->exception); + future->exception = NULL; + } + + if (future->filename != NULL) { + zend_string_release(future->filename); + future->filename = NULL; + } + + if (future->completed_filename != NULL) { + zend_string_release(future->completed_filename); + future->completed_filename = NULL; + } + + zend_async_callbacks_free(event); + zend_async_callbacks_vector_free(&future->resolve_callbacks, event); + + efree(remote); + + return true; +} + +/** @copydoc async_new_remote_future */ +zend_future_remote_t *async_new_remote_future(zend_future_shared_state_t *state) +{ + zend_future_remote_t *remote = ecalloc(1, sizeof(zend_future_remote_t)); + zend_future_t *future = &remote->future; + zend_async_event_t *event = &future->event; + + ZVAL_UNDEF(&future->result); + + /* Standard future handlers */ + event->add_callback = zend_future_add_callback; + event->del_callback = zend_future_del_callback; + event->replay = zend_future_replay; + event->info = zend_future_info; + event->ref_count = 1; + + /* Override start/stop/dispose to proxy through trigger */ + event->start = remote_future_start; + event->stop = remote_future_stop; + event->dispose = remote_future_dispose; + + ZEND_ASYNC_EVENT_SET_ZVAL_RESULT(event); + + future->resolve = zend_future_resolve; + future->resolve_callbacks.data = NULL; + future->resolve_callbacks.length = 0; + future->resolve_callbacks.capacity = 0; + future->filename = NULL; + future->lineno = 0; + future->completed_filename = NULL; + future->completed_lineno = 0; + + /* Bind to shared state */ + remote->state = state; + async_future_shared_state_addref(state); + + /* Bind trigger + target on current thread */ + async_future_shared_state_bind(state, future); + + return remote; +} + /////////////////////////////////////////////////////////// /// Class registration /////////////////////////////////////////////////////////// @@ -1793,6 +2276,7 @@ void async_register_future_ce(void) memcpy(&async_future_state_handlers, &std_object_handlers, sizeof(zend_object_handlers)); async_future_state_handlers.offset = XtOffsetOf(async_future_state_t, std); async_future_state_handlers.free_obj = async_future_state_object_free; + async_future_state_handlers.transfer_obj = async_future_state_transfer_obj; async_ce_future_state->default_object_handlers = &async_future_state_handlers; /* Register Future class using generated registration */ diff --git a/future.h b/future.h index 942e7380..20b39621 100644 --- a/future.h +++ b/future.h @@ -21,6 +21,7 @@ typedef struct _async_future_state_s async_future_state_t; typedef struct _async_future_s async_future_t; +typedef struct _zend_future_shared_state_s zend_future_shared_state_t; /* Mapper types for Future transformations */ typedef enum @@ -37,8 +38,9 @@ typedef enum */ struct _async_future_state_s { - ZEND_ASYNC_EVENT_REF_FIELDS /* Reference to zend_future_t */ - zend_object std; /* Standard object */ + ZEND_ASYNC_EVENT_REF_FIELDS /* Reference to zend_future_t */ + zend_future_shared_state_t *shared_state; /* Non-NULL after transfer to another thread */ + zend_object std; /* Standard object */ }; /** @@ -50,7 +52,7 @@ struct _async_future_state_s struct _async_future_s { ZEND_ASYNC_EVENT_REF_FIELDS /* Reference to zend_future_t (same as FutureState) */ - HashTable *child_futures; /* Child futures created by map/catch/finally */ + HashTable *child_futures; /* Child futures created by map/catch/finally */ zval mapper; /* Mapper callable (used when this future is a child) */ async_future_mapper_type_t mapper_type; /* Type of mapper transformation */ zend_object std; /* Standard object - MUST BE LAST! */ @@ -77,4 +79,183 @@ zend_object *async_new_future_obj(zend_future_t *future); /* Internal helper functions */ async_future_state_t *async_future_state_create(void); +/////////////////////////////////////////////////////////// +/// Shared future state — cross-thread future bridge +/// +/// Bridges two futures across threads via persistent memory. +/// +/// Ownership graph: +/// FutureSRC -> source_cb -> shared_state <- trigger_cb <- FutureDST +/// +/// The shared state is created without a trigger. The destination thread +/// binds its trigger and target future via async_future_shared_state_bind() +/// or by creating a zend_future_remote_t which does this automatically. +/////////////////////////////////////////////////////////// + +/** + * @brief Cross-thread shared future state. + * + * Allocated in persistent memory (pemalloc) so it can be safely + * accessed from multiple threads. Holds transferred result/exception + * and coordinates notification between source and destination threads. + */ +typedef struct _zend_future_shared_state_s { + /** Atomic reference count (+1 source callback, +1 dest callback) */ + zend_atomic_int ref_count; + + /** Atomic completion flag (0 = pending, 1 = completed) */ + zend_atomic_int completed; + + /** Mutex protecting the transition from pending to completed. + * Present only in ZTS builds — threading is ZTS-only. */ +#ifdef ZTS + MUTEX_T mutex; +#endif + + /** Transferred result value in persistent memory */ + zval transferred_result; + + /** Transferred exception in persistent memory (UNDEF if success) */ + zval transferred_exception; + + /** Trigger event bound to the destination thread's event loop */ + zend_async_trigger_event_t *trigger; + + /** Target future in the destination thread (emalloc, not owned) */ + zend_future_t *target_future; +} zend_future_shared_state_t; + +/** + * @brief Create a shared state (without trigger or target). + * + * Allocates shared state in persistent memory. The trigger and target future + * are left NULL — the destination thread must call async_future_shared_state_bind() + * or create a zend_future_remote_t to set them up. + * + * @return Shared state with ref_count = 1 (caller owns one ref). + */ +zend_future_shared_state_t *async_future_shared_state_create(void); + +/** + * @brief Bind a trigger and target future to the shared state. + * + * Must be called from the destination thread. Creates a trigger event + * on the current thread's event loop and subscribes a callback that will + * load the transferred result and complete @p target_future. + * + * @param state The shared state to bind. + * @param target_future The future to complete when the shared state is resolved. + * @return true on success, false if trigger creation failed. + */ +bool async_future_shared_state_bind(zend_future_shared_state_t *state, zend_future_t *target_future); + +/** + * @brief Complete a shared state with a result. Thread-safe. + * + * Transfers @p result to persistent memory and fires the trigger to wake + * the destination thread. No-op if already completed. + * + * @param state The shared state to complete. + * @param result The result value (will be deep-copied to persistent memory). + */ +void async_future_shared_state_complete(zend_future_shared_state_t *state, zval *result); + +/** + * @brief Reject a shared state with an exception. Thread-safe. + * + * Transfers @p exception to persistent memory and fires the trigger to wake + * the destination thread. No-op if already completed. + * + * @param state The shared state to reject. + * @param exception The exception object (will be deep-copied to persistent memory). + */ +void async_future_shared_state_reject(zend_future_shared_state_t *state, zend_object *exception); + +/** + * @brief Increment the shared state reference count. Thread-safe. + * + * @param state The shared state. + */ +static zend_always_inline void async_future_shared_state_addref(zend_future_shared_state_t *state) +{ + int old; + do { + old = zend_atomic_int_load(&state->ref_count); + } while (!zend_atomic_int_compare_exchange(&state->ref_count, &old, old + 1)); +} + +/** + * @brief Free shared state resources (called when ref_count reaches 0). + * + * Releases any unretrieved transferred values and frees the persistent memory. + * Do not call directly — use async_future_shared_state_delref(). + * + * @param state The shared state to destroy. + */ +void async_future_shared_state_destroy(zend_future_shared_state_t *state); + +/** + * @brief Decrement the shared state reference count. Thread-safe. + * + * Destroys the shared state when the last reference is released. + * + * @param state The shared state. + */ +static zend_always_inline void async_future_shared_state_delref(zend_future_shared_state_t *state) +{ + int old; + do { + old = zend_atomic_int_load(&state->ref_count); + } while (!zend_atomic_int_compare_exchange(&state->ref_count, &old, old - 1)); + + if (old == 1) { + async_future_shared_state_destroy(state); + } +} + +/** + * @brief Create a source callback for subscribing to a source future. + * + * When the source future completes, the callback transfers its result + * (or exception) into the shared state and fires the trigger. The callback + * holds one reference to the shared state. + * + * @param state The shared state (ref_count is incremented). + * @return Event callback suitable for add_callback() on the source future. + */ +zend_async_event_callback_t *async_future_shared_state_source_cb(zend_future_shared_state_t *state); + +/////////////////////////////////////////////////////////// +/// Remote future — local future bound to a cross-thread shared state +/// +/// Created in the destination thread. Proxies start/stop/dispose +/// through the trigger so the event loop handle is properly managed. +/////////////////////////////////////////////////////////// + +/** + * @brief Future that receives its result from another thread via shared state. + * + * Extends zend_future_t with a pointer to the shared state. The start/stop/dispose + * handlers proxy to the trigger event so the uv_async handle is ref'd/unref'd + * correctly in the event loop. + */ +typedef struct _zend_future_remote_s { + /** Base future (must be first for casting) */ + zend_future_t future; + + /** Shared state connecting this future to the source thread */ + zend_future_shared_state_t *state; +} zend_future_remote_t; + +/** + * @brief Create a remote future bound to an existing shared state. + * + * Must be called from the destination thread. Creates the trigger on the + * current thread's event loop and binds it to the shared state. + * + * @param state The shared state (ref_count is incremented). + * @return Remote future, or NULL on failure. + */ +zend_future_remote_t *async_new_remote_future(zend_future_shared_state_t *state); + #endif /* FUTURE_H */ \ No newline at end of file diff --git a/ide-stubs/async.php b/ide-stubs/async.php index b9f1d103..cfd48853 100644 --- a/ide-stubs/async.php +++ b/ide-stubs/async.php @@ -914,6 +914,87 @@ public function isCompleted(): bool {} public function isCancelled(): bool {} } +// --------------------------------------------------------------------------- +// Thread Exceptions +// --------------------------------------------------------------------------- + +/** + * Wraps an exception that originated in a child thread. + * The original exception is accessible via getRemoteException(). + * @since 8.6 + */ +class RemoteException extends AsyncException +{ + private ?\Throwable $remoteException = null; + private string $remoteClass = ''; + + /** Get the original exception from the child thread. */ + public function getRemoteException(): ?\Throwable {} + + /** Get the class name of the original exception in the child thread. */ + public function getRemoteClass(): string {} +} + +/** + * Thrown when data transfer between threads fails. + * @since 8.6 + */ +class ThreadTransferException extends AsyncException {} + +// --------------------------------------------------------------------------- +// Thread +// --------------------------------------------------------------------------- + +/** + * Represents a running OS thread. + * + * Obtain a Thread via {@see spawn_thread()}. + * Each thread has its own PHP runtime (TSRM) and event loop. + * + * @since 8.6 + */ +final class Thread implements Completable +{ + private function __construct() {} + + /** + * Return true if the thread is currently running. + */ + public function isRunning(): bool {} + + /** + * Return true if the thread has completed execution. + */ + public function isCompleted(): bool {} + + /** + * Return true if the thread was cancelled. + */ + public function isCancelled(): bool {} + + /** + * Returns the thread result when finished. + * If the thread is not finished, returns null. + */ + public function getResult(): mixed {} + + /** + * Returns the thread exception when finished. + * If the thread is not finished, returns null. + */ + public function getException(): mixed {} + + /** + * Cancel the thread. + */ + public function cancel(?AsyncCancellation $cancellation = null): void {} + + /** + * Define a callback to be executed when the thread is finished. + */ + public function finally(\Closure $callback): void {} +} + // --------------------------------------------------------------------------- // Channel // --------------------------------------------------------------------------- @@ -1541,6 +1622,23 @@ function spawn(callable $task, mixed ...$args): Coroutine {} */ function spawn_with(ScopeProvider $provider, callable $task, mixed ...$args): Coroutine {} +/** + * Spawn a new OS thread that runs the given closure. + * + * The child thread gets its own PHP runtime (TSRM). With OPcache enabled, + * the parent's compiled code is shared via SHM (near-zero overhead). + * Without OPcache, a deep copy is performed (TODO). + * + * @param \Closure $task The closure to execute in the new thread. + * @param bool $inherit If true (default), inherit the parent's function/class tables + * into the child thread. If false, only the closure and + * autoloaders are transferred. + * @param \Closure|null $bootloader Optional closure executed in the thread before $task. + * Use it to set up autoloaders, initialize DI, etc. + * @return Thread A thread handle implementing Completable. + */ +function spawn_thread(\Closure $task, bool $inherit = true, ?\Closure $bootloader = null): Thread {} + /** * Yield control to the scheduler, allowing other coroutines to run. */ diff --git a/libuv_reactor.c b/libuv_reactor.c index 893fc81a..284e282c 100644 --- a/libuv_reactor.c +++ b/libuv_reactor.c @@ -15,11 +15,21 @@ */ #include "libuv_reactor.h" #include +#include +#include
+#include
#include "exceptions.h" #include "php_async.h" +#include "php_main.h" +#include "thread.h" +#include "thread_pool.h" #include "zend_common.h" +#ifdef ZTS +#include "TSRM.h" +#endif + #ifdef PHP_WIN32 #include "win32/unistd.h" #include "win32/codepage.h" @@ -70,6 +80,104 @@ static void libuv_cleanup_signal_events(void); static void libuv_cleanup_process_events(void); static void uv_stat_to_zend_stat(const uv_stat_t *uv_statbuf, zend_stat_t *zend_statbuf); +/////////////////////////////////////////////////////////// +/// Child thread registry +/// +/// Process-global registry that tracks every OS thread spawned by the +/// reactor (both event-backed threads and lightweight pool workers). +/// +/// Purpose: let the main thread block before php_module_shutdown until +/// all child threads have released TSRM and are safe to outrun. +/// +/// - Add: main thread under registry_mutex, after uv_thread_create +/// returns successfully, keyed by the OS handle. +/// - Remove: child thread itself, right after ts_free_thread() and +/// before returning from the OS entry point, so that entries only +/// disappear once the thread has stopped touching Zend/TSRM state. +/// - Quiesce: main thread waits on registry_cond until the table is +/// empty, then caller may proceed into php_module_shutdown. +/////////////////////////////////////////////////////////// + +static HashTable child_thread_registry; +static uv_mutex_t child_thread_registry_mutex; +static uv_cond_t child_thread_registry_cond; +static bool child_thread_registry_inited = false; + +static void libuv_thread_registry_init(void) +{ + if (child_thread_registry_inited) { + return; + } + + zend_hash_init(&child_thread_registry, 8, NULL, NULL, 1 /* persistent */); + + if (uv_mutex_init(&child_thread_registry_mutex) != 0) { + zend_error_noreturn(E_CORE_ERROR, + "libuv: failed to init child_thread_registry_mutex"); + } + + if (uv_cond_init(&child_thread_registry_cond) != 0) { + uv_mutex_destroy(&child_thread_registry_mutex); + zend_error_noreturn(E_CORE_ERROR, + "libuv: failed to init child_thread_registry_cond"); + } + + child_thread_registry_inited = true; +} + +static void libuv_thread_registry_add(zend_async_thread_handle_t handle) +{ + libuv_thread_registry_init(); + + uv_mutex_lock(&child_thread_registry_mutex); + /* value payload is unused — the key alone is what we track */ + zval placeholder; + ZVAL_NULL(&placeholder); + zend_hash_index_add(&child_thread_registry, (zend_ulong) handle, &placeholder); + uv_mutex_unlock(&child_thread_registry_mutex); +} + +static void libuv_thread_registry_remove(zend_async_thread_handle_t handle) +{ + if (!child_thread_registry_inited) { + return; + } + + uv_mutex_lock(&child_thread_registry_mutex); + zend_hash_index_del(&child_thread_registry, (zend_ulong) handle); + if (zend_hash_num_elements(&child_thread_registry) == 0) { + uv_cond_broadcast(&child_thread_registry_cond); + } + uv_mutex_unlock(&child_thread_registry_mutex); +} + +/* {{{ libuv_reactor_quiesce — block until every child thread has + * released TSRM. Called from main thread at the very top of + * php_module_shutdown, before any module gets destroyed. */ +static void libuv_reactor_quiesce(void) +{ +#ifdef ZTS + ZEND_ASSERT(tsrm_is_main_thread()); +#endif + + if (!child_thread_registry_inited) { + return; + } + + uv_mutex_lock(&child_thread_registry_mutex); + while (zend_hash_num_elements(&child_thread_registry) > 0) { + uv_cond_wait(&child_thread_registry_cond, &child_thread_registry_mutex); + } + uv_mutex_unlock(&child_thread_registry_mutex); +} +/* }}} */ + +/* Exposed to thread.c so child threads can self-remove after ts_free_thread. */ +void async_libuv_thread_registry_remove(zend_async_thread_handle_t handle) +{ + libuv_thread_registry_remove(handle); +} + /////////////////////////////////////////////////////////// /// Event info methods for deadlock diagnostics /////////////////////////////////////////////////////////// @@ -1868,14 +1976,310 @@ zend_async_process_event_t *libuv_new_process_event(zend_process_t process_handl /// Thread API ///////////////////////////////////////////////////////////////////////////////// +/* {{{ libuv_thread_notify_cb - called on parent loop when child thread finishes */ +static void libuv_thread_notify_cb(uv_async_t *handle) +{ + async_thread_event_t *thread = handle->data; + + /* Load persistent result/exception into parent thread's emalloc */ + ZEND_ASYNC_THREAD_LOAD_RESULT(&thread->event); + ZEND_ASYNC_CALLBACKS_NOTIFY(&thread->event.base, &thread->event.result, thread->event.exception); + thread->event.base.stop(&thread->event.base); + + if (UNEXPECTED(thread->event.exception != NULL + && false == ZEND_ASYNC_EVENT_IS_EXCEPTION_HANDLED(&thread->event.base))) { + + if (UNEXPECTED(EG(exception) != NULL)) { + zend_exception_set_previous(thread->event.exception, EG(exception)); + EG(exception) = thread->event.exception; + } else { + EG(exception) = thread->event.exception; + } + + thread->event.exception = NULL; + } + + IF_EXCEPTION_STOP_REACTOR; +} + +/* }}} */ + +/* {{{ libuv_thread_notify — callback for notify_parent */ +static void libuv_thread_notify(zend_async_thread_event_t *event) +{ + async_thread_event_t *thread = (async_thread_event_t *) event; + uv_async_send(&thread->uv_notify); +} + +/* }}} */ + +/* {{{ libuv_thread_event_start */ +static bool libuv_thread_event_start(zend_async_event_t *event) +{ + EVENT_START_PROLOGUE(event); + + async_thread_event_t *thread = (async_thread_event_t *) event; + + /* Add ref on context for the thread runner */ + if (thread->event.context) { + thread->event.context->event = &thread->event; + ZEND_ASYNC_THREAD_CONTEXT_ADDREF(thread->event.context); + } + + /* Ensure registry exists before uv_thread_create — once the child is + * running, it may race us to the first self-remove call. */ + libuv_thread_registry_init(); + + const int ret = uv_thread_create(&thread->uv_handle, zend_async_thread_run_fn, thread->event.context); + + if (UNEXPECTED(ret != 0)) { + if (thread->event.context) { + zend_atomic_int_dec(&thread->event.context->ref_count); + } + + async_throw_error("Failed to create thread: %s", uv_strerror(ret)); + return false; + } + + if (thread->event.context) { + thread->event.context->handle = (zend_async_thread_handle_t) thread->uv_handle; + libuv_thread_registry_add(thread->event.context->handle); + } + + event->loop_ref_count++; + ZEND_ASYNC_INCREASE_EVENT_COUNT(event); + return true; +} + +/* }}} */ + +/* {{{ libuv_thread_event_stop */ +static bool libuv_thread_event_stop(zend_async_event_t *event) +{ + EVENT_STOP_PROLOGUE(event); + + async_thread_event_t *thread = (async_thread_event_t *) event; + + /* Unref async handle so it doesn't keep the event loop alive. + * Actual uv_close happens in dispose when refcount reaches 0. */ + uv_unref((uv_handle_t *) &thread->uv_notify); + + ZEND_ASYNC_EVENT_SET_CLOSED(event); + event->loop_ref_count = 0; + ZEND_ASYNC_DECREASE_EVENT_COUNT(event); + return true; +} + +/* }}} */ + +/* {{{ libuv_thread_event_dispose */ +static bool libuv_thread_event_dispose(zend_async_event_t *event) +{ + if (ZEND_ASYNC_EVENT_REFCOUNT(event) > 1) { + ZEND_ASYNC_EVENT_DEL_REF(event); + return true; + } + + if (event->loop_ref_count > 0) { + event->loop_ref_count = 1; + event->stop(event); + } + + zend_async_callbacks_free(event); + + async_thread_event_t *thread = (async_thread_event_t *) event; + + /* Release event's ref on context */ + if (thread->event.context) { + ZEND_ASYNC_THREAD_CONTEXT_RELEASE(thread->event.context); + thread->event.context = NULL; + } + + if (thread->event.filename) { + zend_string_release(thread->event.filename); + thread->event.filename = NULL; + } + + /* Result/exception cleanup depends on whether notify_cb has + * converted them from pemalloc to emalloc */ + if (ZEND_THREAD_IS_RESULT_LOADED(&thread->event)) { + zval_ptr_dtor(&thread->event.result); + if (thread->event.exception) { + OBJ_RELEASE(thread->event.exception); + } + } else { + if (!Z_ISUNDEF(thread->event.result)) { + async_thread_release_transferred_zval(&thread->event.result); + } + if (thread->event.exception) { + zval exc_pz; + ZVAL_OBJ(&exc_pz, thread->event.exception); + async_thread_release_transferred_zval(&exc_pz); + } + } + ZVAL_UNDEF(&thread->event.result); + thread->event.exception = NULL; + + uv_close((uv_handle_t *) &thread->uv_notify, libuv_close_handle_cb); + return true; +} + +/* }}} */ + +/* {{{ libuv_thread_replay — replay result/exception for already-completed thread */ +static bool libuv_thread_replay(zend_async_event_t *event, + zend_async_event_callback_t *callback, zval *result, zend_object **exception) +{ + zend_async_thread_event_t *thread_event = (zend_async_thread_event_t *) event; + + if (!ZEND_ASYNC_EVENT_IS_CLOSED(event)) { + return false; + } + + if (callback != NULL) { + callback->callback(event, callback, &thread_event->result, thread_event->exception); + return true; + } + + if (result != NULL && !Z_ISUNDEF(thread_event->result)) { + ZVAL_COPY(result, &thread_event->result); + } + + if (exception == NULL && thread_event->exception != NULL) { + zval exc_zv; + ZVAL_OBJ_COPY(&exc_zv, thread_event->exception); + zend_throw_exception_object(&exc_zv); + } else if (exception != NULL && thread_event->exception != NULL) { + *exception = thread_event->exception; + GC_ADDREF(*exception); + } + + return thread_event->exception != NULL || !Z_ISUNDEF(thread_event->result); +} + +/* }}} */ + +/* {{{ libuv_thread_info */ +static zend_string *libuv_thread_info(zend_async_event_t *event) +{ + async_thread_event_t *thread = (async_thread_event_t *) event; + + int64_t tid = thread->event.context ? zend_atomic_int64_load(&thread->event.context->thread_id) : 0; + + return zend_strpprintf(0, "thread #%" PRId64 " spawned at %s:%u", + tid, + thread->event.filename ? ZSTR_VAL(thread->event.filename) : "unknown", + thread->event.lineno); +} + +/* }}} */ + /* {{{ libuv_new_thread_event */ -zend_async_thread_event_t *libuv_new_thread_event(zend_async_thread_entry_t entry, void *arg, size_t extra_size) +zend_async_thread_event_t *libuv_new_thread_event( + const zend_fcall_t *entry, const zend_fcall_t *bootloader, const uint32_t thread_flags, const size_t extra_size) { - // TODO: libuv_new_thread_event - // We need to design a mechanism for creating a Thread and running a function - // in another thread in such a way that it can be awaited like an event. - // - return NULL; + START_REACTOR_OR_RETURN_NULL; + + const size_t alloc_size = extra_size != 0 + ? sizeof(async_thread_event_t) + extra_size + : sizeof(async_thread_event_t); + + /* ecalloc (persistent=0): lives in parent's emalloc heap. + * Safe because parent outlives child thread (uv_thread_join in stop). */ + async_thread_event_t *thread_event = pecalloc(1, alloc_size, 0); + + thread_event->event.thread_flags = thread_flags; + thread_event->event.base.extra_offset = sizeof(async_thread_event_t); + thread_event->event.base.ref_count = 1; + + thread_event->event.base.add_callback = libuv_add_callback; + thread_event->event.base.del_callback = libuv_remove_callback; + thread_event->event.base.start = libuv_thread_event_start; + thread_event->event.base.stop = libuv_thread_event_stop; + thread_event->event.base.dispose = libuv_thread_event_dispose; + thread_event->event.base.replay = libuv_thread_replay; + thread_event->event.base.info = libuv_thread_info; + + ZVAL_UNDEF(&thread_event->event.result); + thread_event->event.exception = NULL; + thread_event->event.filename = NULL; + thread_event->event.lineno = 0; + + /* Set notify callback for child → parent notification */ + thread_event->event.notify_parent = libuv_thread_notify; + + /* Create thread context (persistent, ref-counted) */ + zend_async_thread_context_t *ctx = pecalloc(1, sizeof(zend_async_thread_context_t), 1); + ZEND_ATOMIC_INT_INIT(&ctx->ref_count, 1); /* event holds one ref */ + ZEND_ATOMIC_INT64_INIT(&ctx->thread_id, 0); + ctx->snapshot = NULL; + ctx->bailout_error_message = NULL; + ctx->event = NULL; /* set in start when thread is launched */ + ctx->internal_entry = NULL; + + /* Create snapshot: deep-copy entry closure + optional bootloader + parent context. + * Snapshot creation may fail (return NULL) if a captured variable's + * transfer_obj handler refuses — e.g. FutureState already transferred + * to another thread. The exception is already set by the handler. */ + if (entry != NULL) { + ctx->snapshot = ZEND_ASYNC_THREAD_SNAPSHOT_CREATE(entry, bootloader); + if (UNEXPECTED(ctx->snapshot == NULL)) { + pefree(ctx, 1); + pefree(thread_event, 0); + return NULL; + } + } + + thread_event->event.context = ctx; + + /* Initialize cross-thread notification handle */ + const int ret = uv_async_init(UVLOOP, &thread_event->uv_notify, libuv_thread_notify_cb); + + if (UNEXPECTED(ret != 0)) { + if (ctx->snapshot) { + ZEND_ASYNC_THREAD_SNAPSHOT_DESTROY(ctx->snapshot); + } + pefree(ctx, 1); + pefree(thread_event, 0); + async_throw_error("Failed to init thread notification: %s", uv_strerror(ret)); + return NULL; + } + + thread_event->uv_notify.data = thread_event; + + return &thread_event->event; +} + +/* }}} */ + +/* {{{ libuv_start_thread — start lightweight thread via async_thread_run */ +static zend_async_thread_handle_t libuv_start_thread( + zend_async_thread_internal_entry_t *entry, zend_async_thread_context_t *context) +{ + /* entry ownership moves to context */ + context->internal_entry = entry; + + /* Add ref on context for the runner */ + ZEND_ASYNC_THREAD_CONTEXT_ADDREF(context); + + /* Ensure registry exists before uv_thread_create — once the child is + * running, it may race us to the first self-remove call. */ + libuv_thread_registry_init(); + + uv_thread_t uv_handle; + const int ret = uv_thread_create(&uv_handle, zend_async_thread_run_fn, context); + + if (UNEXPECTED(ret != 0)) { + zend_atomic_int_dec(&context->ref_count); + context->internal_entry = NULL; + async_throw_error("Failed to create thread: %s", uv_strerror(ret)); + return 0; + } + + context->handle = (zend_async_thread_handle_t) uv_handle; + libuv_thread_registry_add(context->handle); + + return (zend_async_thread_handle_t) uv_handle; } /* }}} */ @@ -3186,6 +3590,9 @@ static bool libuv_trigger_event_start(zend_async_event_t *event) { EVENT_START_PROLOGUE(event); + async_trigger_event_t *trigger = (async_trigger_event_t *) event; + uv_ref((uv_handle_t *) &trigger->uv_handle); + event->loop_ref_count++; ZEND_ASYNC_INCREASE_EVENT_COUNT(event); return true; @@ -3198,6 +3605,9 @@ static bool libuv_trigger_event_stop(zend_async_event_t *event) { EVENT_STOP_PROLOGUE(event); + async_trigger_event_t *trigger = (async_trigger_event_t *) event; + uv_unref((uv_handle_t *) &trigger->uv_handle); + event->loop_ref_count = 0; ZEND_ASYNC_DECREASE_EVENT_COUNT(event); return true; @@ -3244,6 +3654,10 @@ zend_async_trigger_event_t *libuv_new_trigger_event(size_t extra_size) return NULL; } + /* Handle is initialized but should not keep the event loop alive + * until start() is explicitly called (e.g. when a coroutine suspends). */ + uv_unref((uv_handle_t *) &trigger->uv_handle); + // Link the handle to the trigger event trigger->uv_handle.data = trigger; trigger->event.base.extra_offset = sizeof(async_trigger_event_t); @@ -4561,6 +4975,7 @@ void async_libuv_reactor_register(void) libuv_reactor_shutdown, libuv_reactor_execute, libuv_reactor_loop_alive, + libuv_reactor_quiesce, libuv_new_socket_event, libuv_new_poll_event, libuv_new_poll_proxy_event, @@ -4593,5 +5008,9 @@ void async_libuv_reactor_register(void) libuv_io_set_option, libuv_udp_set_membership); - zend_async_thread_pool_register(LIBUV_REACTOR_NAME, false, libuv_new_task, libuv_queue_task); + zend_async_thread_pool_register(LIBUV_REACTOR_NAME, false, + libuv_new_task, libuv_queue_task, + async_thread_pool_create, libuv_start_thread, + async_thread_transfer_zval_ctx, async_thread_load_zval_ctx, + async_thread_xlat_put_ctx, async_thread_defer_release_ctx); } diff --git a/libuv_reactor.h b/libuv_reactor.h index eb371e1f..643383cc 100644 --- a/libuv_reactor.h +++ b/libuv_reactor.h @@ -19,6 +19,7 @@ #define LIBUV_REACTOR_VERSION "0.8.0" #define LIBUV_REACTOR_NAME "Libuv Reactor 0.8.0" #include +#include "thread.h" #ifdef PHP_WIN32 #include "libuv/uv.h" @@ -96,6 +97,7 @@ struct _async_thread_event_t { zend_async_thread_event_t event; uv_thread_t uv_handle; + uv_async_t uv_notify; /* Cross-thread notification handle */ }; struct _async_exec_event_t @@ -174,4 +176,8 @@ struct _async_udp_req_t void async_libuv_reactor_register(void); +/* Called by async_thread_run in the child thread after ts_free_thread, so + * the registry entry vanishes only once the child is past TSRM/Zend access. */ +void async_libuv_thread_registry_remove(zend_async_thread_handle_t handle); + #endif // LIBUV_REACTOR_H diff --git a/pool.c b/pool.c index 1379d168..fcea3192 100644 --- a/pool.c +++ b/pool.c @@ -337,6 +337,7 @@ static void pool_strategy_report_failure(async_pool_t *pool, zend_object *error) } zval retval, source, error_zval; + bool owns_error = false; ZVAL_UNDEF(&retval); if (base->wrapper) { @@ -348,10 +349,17 @@ static void pool_strategy_report_failure(async_pool_t *pool, zend_object *error) if (error) { ZVAL_OBJ(&error_zval, error); } else { - /* Create a generic exception if none provided */ - zend_object *ex = zend_throw_exception(NULL, "Resource validation failed", 0); - zend_clear_exception(); - ZVAL_OBJ(&error_zval, ex); + /* Create a generic exception if none provided. Construct it directly + * without going through zend_throw_exception(), which would transfer + * ownership to EG(exception) and then zend_clear_exception() would + * free the object — leaving a dangling pointer. */ + object_init_ex(&error_zval, zend_ce_exception); + zval msg; + ZVAL_STRING(&msg, "Resource validation failed"); + zend_update_property_ex(zend_ce_exception, Z_OBJ(error_zval), + ZSTR_KNOWN(ZEND_STR_MESSAGE), &msg); + zval_ptr_dtor(&msg); + owns_error = true; } zend_call_method_with_2_params(base->strategy.object, NULL, NULL, "reportFailure", &retval, &source, &error_zval); @@ -361,6 +369,9 @@ static void pool_strategy_report_failure(async_pool_t *pool, zend_object *error) } zval_ptr_dtor(&retval); + if (owns_error) { + zval_ptr_dtor(&error_zval); + } } /////////////////////////////////////////////////////////////////////////////// diff --git a/scope.c b/scope.c index 9ae87a9b..9a65165f 100644 --- a/scope.c +++ b/scope.c @@ -598,6 +598,28 @@ typedef struct async_scope_t *scope; } scope_timeout_callback_t; +/* Custom dispose: release the scope ref added by disposeAfterTimeout() when + * the callback is freed without having transferred ownership to a cancellation + * coroutine (e.g. timer cancelled before firing). If scope was cleared by + * scope_timeout_callback() — ownership was already transferred and there is + * nothing to release here. */ +static void scope_timeout_callback_dispose(zend_async_event_callback_t *callback, zend_async_event_t *event) +{ + if (callback->ref_count > 1) { + callback->ref_count--; + return; + } + + scope_timeout_callback_t *scope_callback = (scope_timeout_callback_t *) callback; + if (scope_callback->scope != NULL) { + ZEND_ASYNC_EVENT_RELEASE(&scope_callback->scope->scope.event); + scope_callback->scope = NULL; + } + + callback->ref_count = 0; + efree(callback); +} + static void scope_timeout_coroutine_entry(void) { zend_coroutine_t *coroutine = ZEND_ASYNC_CURRENT_COROUTINE; @@ -613,6 +635,10 @@ static void scope_timeout_coroutine_entry(void) async_new_exception(async_ce_cancellation_exception, "Scope has been disposed due to timeout"); ZEND_ASYNC_SCOPE_CANCEL(&scope->scope, exception, false, ZEND_ASYNC_SCOPE_IS_DISPOSE_SAFELY(&scope->scope)); + + /* Release the ref added in disposeAfterTimeout() and transferred to this + * coroutine via scope_timeout_callback(). */ + ZEND_ASYNC_EVENT_RELEASE(&scope->scope.event); } static void scope_timeout_callback(zend_async_event_t *event, @@ -622,10 +648,18 @@ static void scope_timeout_callback(zend_async_event_t *event, { scope_timeout_callback_t *scope_callback = (scope_timeout_callback_t *) callback; async_scope_t *scope = scope_callback->scope; + + /* Take ownership of the scope ref out of the callback so our dispose + * handler does not release it when the timer event frees its callbacks. */ + scope_callback->scope = NULL; + event->dispose(event); zend_coroutine_t *coroutine = ZEND_ASYNC_SPAWN_WITH(ZEND_ASYNC_MAIN_SCOPE); if (UNEXPECTED(coroutine == NULL)) { + if (scope != NULL) { + ZEND_ASYNC_EVENT_RELEASE(&scope->scope.event); + } return; } @@ -671,15 +705,24 @@ METHOD(disposeAfterTimeout) RETURN_THROWS(); } + callback->callback.dispose = scope_timeout_callback_dispose; callback->scope = scope_object->scope; - callback->scope->scope.event.ref_count++; + ZEND_ASYNC_EVENT_ADD_REF(&callback->scope->scope.event); if (!timer_event->base.add_callback(&timer_event->base, &callback->callback)) { + /* add_callback failed — the callback was not taken by the event, so our + * custom dispose won't run. Release the scope ref and free the callback + * by hand. */ + ZEND_ASYNC_EVENT_RELEASE(&callback->scope->scope.event); + callback->scope = NULL; + efree(callback); timer_event->base.dispose(&timer_event->base); return; } if (!timer_event->base.start(&timer_event->base)) { + /* Timer's dispose will free callbacks (including ours), which triggers + * scope_timeout_callback_dispose and releases the scope ref. */ timer_event->base.dispose(&timer_event->base); return; } diff --git a/task_group.c b/task_group.c index 18d2cfc8..7fd16feb 100644 --- a/task_group.c +++ b/task_group.c @@ -69,7 +69,7 @@ struct _task_group_waiter_event_s }; /* Forward declarations */ -static void task_group_waiter_event_remove(const task_group_waiter_event_t *waiter); +static void task_group_waiter_event_remove(task_group_waiter_event_t *waiter); /////////////////////////////////////////////////////////// /// Waiter event vtable (ITERATOR only — lightweight event) @@ -173,7 +173,7 @@ static task_group_waiter_event_t *task_group_waiter_future_new(async_task_group_ return waiter; } -static void task_group_waiter_event_remove(const task_group_waiter_event_t *waiter) +static void task_group_waiter_event_remove(task_group_waiter_event_t *waiter) { async_task_group_t *group = waiter->group; @@ -190,9 +190,15 @@ static void task_group_waiter_event_remove(const task_group_waiter_event_t *wait if (i < group->waiter_notify_index) { group->waiter_notify_index--; } - return; + break; } } + + /* Detach from the group so a later dispose does not touch a freed + * task_group — the synchronous-settled paths in all()/race()/any() + * detach the waiter without wrapping it in dispose, leaving the wrapper + * Future to outlive the task_group object. */ + waiter->group = NULL; } #define METHOD(name) PHP_METHOD(Async_TaskGroup, name) @@ -1418,6 +1424,12 @@ METHOD(all) } task_set_remove_all_entries(group); + /* Detach from the group's waiter vector now that we've resolved + * synchronously — otherwise task_group_free_object() would force a + * second dispose on the same waiter while the returned Future + * wrapper still holds its pointer, producing a use-after-free at + * shutdown. Mirrors the drain-path cleanup in task_group_drain(). */ + task_group_waiter_event_remove(waiter); } RETURN_OBJ(ZEND_ASYNC_NEW_FUTURE_OBJ(&waiter->future)); @@ -1447,6 +1459,8 @@ METHOD(race) if (task_is_completed(zv)) { ZEND_FUTURE_COMPLETE(&waiter->future, zv); task_set_remove_entry(group, str_key, num_key); + /* Detach: see explanation in METHOD(all). */ + task_group_waiter_event_remove(waiter); goto return_future; } if (task_is_error(zv)) { @@ -1454,6 +1468,8 @@ METHOD(race) ZEND_FUTURE_REJECT(&waiter->future, entry->exception); // ZEND_FUTURE_SET_EXCEPTION_CAUGHT(&waiter->future); task_set_remove_entry(group, str_key, num_key); + /* Detach: see explanation in METHOD(all). */ + task_group_waiter_event_remove(waiter); goto return_future; } } @@ -1487,6 +1503,8 @@ METHOD(any) if (task_is_completed(zv)) { ZEND_FUTURE_COMPLETE(&waiter->future, zv); task_set_remove_entry(group, str_key, num_key); + /* Detach: see explanation in METHOD(all). */ + task_group_waiter_event_remove(waiter); goto return_future; } } @@ -1498,6 +1516,8 @@ METHOD(any) ZEND_FUTURE_REJECT(&waiter->future, composite); // ZEND_FUTURE_SET_EXCEPTION_CAUGHT(&waiter->future); OBJ_RELEASE(composite); + /* Detach: see explanation in METHOD(all). */ + task_group_waiter_event_remove(waiter); } return_future: diff --git a/tests/channel/039-channel_iterator_unbuffered.phpt b/tests/channel/039-channel_iterator_unbuffered.phpt new file mode 100644 index 00000000..07d3c623 --- /dev/null +++ b/tests/channel/039-channel_iterator_unbuffered.phpt @@ -0,0 +1,36 @@ +--TEST-- +Channel: foreach on unbuffered (rendezvous) channel reads directly from the rendezvous slot +--FILE-- +rendezvous_value +// and clears the rendezvous slot. Existing test 008 uses a buffered +// channel and exercises the zval_circular_buffer_pop branch instead. + +$ch = new Channel(0); // capacity 0 = unbuffered / rendezvous + +spawn(function() use ($ch) { + foreach (['x', 'y', 'z'] as $v) { + $ch->send($v); + } + $ch->close(); +}); + +spawn(function() use ($ch) { + foreach ($ch as $value) { + echo "got $value\n"; + } + echo "done\n"; +}); + +?> +--EXPECT-- +got x +got y +got z +done diff --git a/tests/channel/040-channel_foreach_by_ref.phpt b/tests/channel/040-channel_foreach_by_ref.phpt new file mode 100644 index 00000000..22850b74 --- /dev/null +++ b/tests/channel/040-channel_foreach_by_ref.phpt @@ -0,0 +1,29 @@ +--TEST-- +Channel: foreach by reference is forbidden +--FILE-- +send(1); + $ch->send(2); + $ch->close(); + + try { + foreach ($ch as &$v) { + echo "should-not: $v\n"; + } + unset($v); + } catch (\Error $e) { + echo "by-ref: ", $e->getMessage(), "\n"; + } +}); + +?> +--EXPECT-- +by-ref: Cannot iterate channel by reference diff --git a/tests/common/await_any_of_exception_releases_arrays.phpt b/tests/common/await_any_of_exception_releases_arrays.phpt new file mode 100644 index 00000000..043d632c --- /dev/null +++ b/tests/common/await_any_of_exception_releases_arrays.phpt @@ -0,0 +1,27 @@ +--TEST-- +Async\await_any_of(): exception from await_futures releases results/errors arrays +--FILE-- + +--EXPECT-- +caught: Async\AsyncException diff --git a/tests/common/await_same_cancellation.phpt b/tests/common/await_same_cancellation.phpt new file mode 100644 index 00000000..3172156a --- /dev/null +++ b/tests/common/await_same_cancellation.phpt @@ -0,0 +1,27 @@ +--TEST-- +Async\await(): same awaitable and cancellation object clears the cancellation slot +--FILE-- +complete("value"); + // Pass the same future as both the target and cancellation. + var_dump(await($f, $f)); +}); + +$result = \Async\await($coroutine); + +?> +--EXPECT-- +string(5) "value" diff --git a/tests/common/current_context_at_root.phpt b/tests/common/current_context_at_root.phpt new file mode 100644 index 00000000..0b58013d --- /dev/null +++ b/tests/common/current_context_at_root.phpt @@ -0,0 +1,29 @@ +--TEST-- +Async\current_context() and Async\coroutine_context() called at script root return fresh contexts +--FILE-- +set('key', 'from-current'); +var_dump($ctx2->has('key')); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(false) diff --git a/tests/common/current_coroutine_not_in_coroutine.phpt b/tests/common/current_coroutine_not_in_coroutine.phpt new file mode 100644 index 00000000..aaa6660a --- /dev/null +++ b/tests/common/current_coroutine_not_in_coroutine.phpt @@ -0,0 +1,21 @@ +--TEST-- +Async\current_coroutine(): throws at the script root (no current coroutine) +--FILE-- +getMessage(), "\n"; +} + +?> +--EXPECT-- +caught: The current coroutine is not defined diff --git a/tests/common/timeout_class_methods.phpt b/tests/common/timeout_class_methods.phpt new file mode 100644 index 00000000..d64f4f69 --- /dev/null +++ b/tests/common/timeout_class_methods.phpt @@ -0,0 +1,49 @@ +--TEST-- +Async\Timeout: direct construct forbidden, isCompleted/isCancelled/cancel methods +--FILE-- +getMessage() . "\n"; +} + +// 2. Created via Async\timeout() — initially not completed. +$t = timeout(5000); +var_dump($t instanceof Timeout); +var_dump($t->isCompleted()); +var_dump($t->isCancelled()); + +// 3. cancel() releases the underlying timer. +$t->cancel(); +var_dump($t->isCancelled()); +var_dump($t->isCompleted()); + +// 4. cancel() on an already-cancelled Timeout is idempotent. +$t->cancel(); +echo "double cancel ok\n"; + +echo "end\n"; + +?> +--EXPECTF-- +start +construct: %A +bool(true) +bool(false) +bool(false) +bool(true) +bool(true) +double cancel ok +end diff --git a/tests/common/timeout_value_error.phpt b/tests/common/timeout_value_error.phpt new file mode 100644 index 00000000..cfd0e7ab --- /dev/null +++ b/tests/common/timeout_value_error.phpt @@ -0,0 +1,24 @@ +--TEST-- +Async\timeout(): ValueError on non-positive duration +--FILE-- +getMessage(), "\n"; + } +} + +?> +--EXPECT-- +ms=0: Timeout value must be greater than 0 +ms=-1: Timeout value must be greater than 0 +ms=-1000: Timeout value must be greater than 0 diff --git a/tests/context/007-context_three_level_inheritance.phpt b/tests/context/007-context_three_level_inheritance.phpt new file mode 100644 index 00000000..02bcb347 --- /dev/null +++ b/tests/context/007-context_three_level_inheritance.phpt @@ -0,0 +1,48 @@ +--TEST-- +Context: three-level scope hierarchy walks past intermediate empty contexts +--FILE-- +spawn(function () { + current_context()->set('grandkey', 'value-from-grand'); +})); + +// Touch middle's context so the lookup can walk past it. +await($middle->spawn(function () { + current_context()->set('middlekey', 'value-from-middle'); +})); + +await($leaf->spawn(function () { + $ctx = current_context(); + var_dump($ctx->find('grandkey')); + var_dump($ctx->has('grandkey')); + // Key absent at every level — find() should return null after walking up. + var_dump($ctx->find('missing')); + var_dump($ctx->has('missing')); +})); + +echo "end\n"; + +?> +--EXPECT-- +start +string(16) "value-from-grand" +bool(true) +NULL +bool(false) +end diff --git a/tests/context/008-context_get_missing.phpt b/tests/context/008-context_get_missing.phpt new file mode 100644 index 00000000..19b07c50 --- /dev/null +++ b/tests/context/008-context_get_missing.phpt @@ -0,0 +1,21 @@ +--TEST-- +Context: get() returns null for missing keys +--FILE-- +set('present', 'value'); + +var_dump($ctx->get('present')); +var_dump($ctx->get('missing')); + +?> +--EXPECT-- +string(5) "value" +NULL diff --git a/tests/edge_cases/012-awaitCompletion_on_cancelled_scope.phpt b/tests/edge_cases/012-awaitCompletion_on_cancelled_scope.phpt new file mode 100644 index 00000000..edfd845a --- /dev/null +++ b/tests/edge_cases/012-awaitCompletion_on_cancelled_scope.phpt @@ -0,0 +1,47 @@ +--TEST-- +Scope: awaitCompletion() throws AsyncCancellation on a cancelled (but not-yet-closed) scope +--FILE-- +asNotSafely(); +$scope->spawn(function () { + try { + while (true) { + suspend(); + } + } catch (\Async\AsyncCancellation $e) { + } +}); + +suspend(); + +$scope->cancel(new \Async\AsyncCancellation("bye")); + +var_dump($scope->isCancelled()); +var_dump($scope->isClosed()); + +try { + $scope->awaitCompletion(timeout(1000)); + echo "no error\n"; +} catch (\Async\AsyncCancellation $e) { + echo "caught: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +bool(true) +bool(false) +caught: The scope has been cancelled +end diff --git a/tests/edge_cases/013-composite_exception_direct.phpt b/tests/edge_cases/013-composite_exception_direct.phpt new file mode 100644 index 00000000..8dfe322a --- /dev/null +++ b/tests/edge_cases/013-composite_exception_direct.phpt @@ -0,0 +1,42 @@ +--TEST-- +CompositeException: addException() and getExceptions() direct usage +--FILE-- +getExceptions(); +var_dump($empty); + +$c->addException(new \RuntimeException("first")); +$c->addException(new \LogicException("second")); +$c->addException(new \Exception("third")); + +$list = $c->getExceptions(); +var_dump(count($list)); + +foreach ($list as $i => $e) { + echo $i, ": ", get_class($e), " - ", $e->getMessage(), "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +array(0) { +} +int(3) +0: RuntimeException - first +1: LogicException - second +2: Exception - third +end diff --git a/tests/edge_cases/014-graceful_shutdown_explicit.phpt b/tests/edge_cases/014-graceful_shutdown_explicit.phpt new file mode 100644 index 00000000..58e43506 --- /dev/null +++ b/tests/edge_cases/014-graceful_shutdown_explicit.phpt @@ -0,0 +1,38 @@ +--TEST-- +Async\graceful_shutdown(): explicit call from userland exits cleanly +--FILE-- + +--EXPECTF-- +start +calling graceful_shutdown +%A diff --git a/tests/exec/012-proc_close_child_killed_by_signal.phpt b/tests/exec/012-proc_close_child_killed_by_signal.phpt index a89e3354..259e8457 100644 --- a/tests/exec/012-proc_close_child_killed_by_signal.phpt +++ b/tests/exec/012-proc_close_child_killed_by_signal.phpt @@ -4,13 +4,16 @@ proc_close() after child process killed by signal (SIGSEGV) /dev/null'); +if ($ldd && strpos($ldd, 'libasan') !== false) die('skip ASAN intercepts SIGSEGV and converts it to exit(1)'); ?> --FILE-- diff --git a/tests/future/031-future_status_accessors.phpt b/tests/future/031-future_status_accessors.phpt new file mode 100644 index 00000000..87115a0c --- /dev/null +++ b/tests/future/031-future_status_accessors.phpt @@ -0,0 +1,56 @@ +--TEST-- +Future: isCompleted()/isCancelled() across pending, completed, rejected and cancelled states +--FILE-- +isCompleted()); + var_dump($future->isCancelled()); + + // Completed successfully + $state->complete(42); + var_dump($future->isCompleted()); + var_dump($future->isCancelled()); + $future->ignore(); + + // Rejected with a generic exception — completed but not cancelled + $state2 = new FutureState(); + $future2 = new Future($state2); + $state2->error(new \RuntimeException("boom")); + var_dump($future2->isCompleted()); + var_dump($future2->isCancelled()); + $future2->ignore(); + + // Rejected with AsyncCancellation — both flags true + $state3 = new FutureState(); + $future3 = new Future($state3); + $state3->error(new AsyncCancellation("stop")); + var_dump($future3->isCompleted()); + var_dump($future3->isCancelled()); + $future3->ignore(); +}); + +await($coroutine); + +?> +--EXPECT-- +bool(false) +bool(false) +bool(true) +bool(false) +bool(true) +bool(false) +bool(true) +bool(true) diff --git a/tests/future/032-future_cancel.phpt b/tests/future/032-future_cancel.phpt new file mode 100644 index 00000000..7db65cfc --- /dev/null +++ b/tests/future/032-future_cancel.phpt @@ -0,0 +1,54 @@ +--TEST-- +Future: cancel() rejects pending future; idempotent on already-completed; custom cancellation +--FILE-- +cancel(); + var_dump($future->isCompleted()); + var_dump($future->isCancelled()); + try { + await($future); + } catch (AsyncCancellation $e) { + echo "msg1: ", $e->getMessage(), "\n"; + } + + // 2. cancel() with explicit cancellation object + $state2 = new FutureState(); + $future2 = new Future($state2); + $future2->cancel(new AsyncCancellation("custom reason")); + try { + await($future2); + } catch (AsyncCancellation $e) { + echo "msg2: ", $e->getMessage(), "\n"; + } + + // 3. cancel() on an already-completed future is a no-op + $state3 = new FutureState(); + $future3 = new Future($state3); + $state3->complete(42); + $future3->cancel(); + var_dump(await($future3)); +}); + +await($coroutine); + +?> +--EXPECT-- +bool(true) +bool(true) +msg1: Future has been cancelled +msg2: custom reason +int(42) diff --git a/tests/future/033-future_getAwaitingInfo.phpt b/tests/future/033-future_getAwaitingInfo.phpt new file mode 100644 index 00000000..fb893f5a --- /dev/null +++ b/tests/future/033-future_getAwaitingInfo.phpt @@ -0,0 +1,39 @@ +--TEST-- +Future: getAwaitingInfo() returns single-element array with FutureState info string +--FILE-- +getAwaitingInfo(); + var_dump(is_array($info)); + var_dump(count($info)); + // Entry is a non-empty string produced by zend_future_info() + var_dump(is_string($info[0]) && strlen($info[0]) > 0); + echo "contains pending: ", (strpos($info[0], "pending") !== false ? "yes" : "no"), "\n"; + + $state->complete(42); + $info = $future->getAwaitingInfo(); + echo "contains completed: ", (strpos($info[0], "completed") !== false ? "yes" : "no"), "\n"; + + $future->ignore(); +}); + +await($coroutine); + +?> +--EXPECT-- +bool(true) +int(1) +bool(true) +contains pending: yes +contains completed: yes diff --git a/tests/future/034-future_state_double_resolve.phpt b/tests/future/034-future_state_double_resolve.phpt new file mode 100644 index 00000000..cefd0446 --- /dev/null +++ b/tests/future/034-future_state_double_resolve.phpt @@ -0,0 +1,56 @@ +--TEST-- +FutureState: double complete()/error() raises AsyncError with original location +--FILE-- +complete(1); + try { + $state->complete(2); + } catch (\Async\AsyncException $e) { + echo "double complete: ", preg_replace('/\d+$/', 'N', $e->getMessage()), "\n"; + } + $future->ignore(); + + // 2. error() after complete() + $state2 = new FutureState(); + $future2 = new Future($state2); + $state2->complete(1); + try { + $state2->error(new \RuntimeException("x")); + } catch (\Async\AsyncException $e) { + echo "error-after-complete: ", preg_replace('/\d+$/', 'N', $e->getMessage()), "\n"; + } + $future2->ignore(); + + // 3. complete() after error() + $state3 = new FutureState(); + $future3 = new Future($state3); + $state3->error(new \RuntimeException("x")); + try { + $state3->complete(1); + } catch (\Async\AsyncException $e) { + echo "complete-after-error: ", preg_replace('/\d+$/', 'N', $e->getMessage()), "\n"; + } + $future3->ignore(); +}); + +await($coroutine); + +?> +--EXPECTF-- +double complete: FutureState is already completed at %s.php:N +error-after-complete: FutureState is already completed at %s.php:N +complete-after-error: FutureState is already completed at %s.php:N diff --git a/tests/future/035-finally_exception_chain.phpt b/tests/future/035-finally_exception_chain.phpt new file mode 100644 index 00000000..ef5e2593 --- /dev/null +++ b/tests/future/035-finally_exception_chain.phpt @@ -0,0 +1,39 @@ +--TEST-- +Future::finally() - handler throwing on a rejected parent chains the parent as previous +--FILE-- +previous is set to the original parent exception. + +$coroutine = spawn(function() { + $state = new FutureState(); + $future = new Future($state); + + $result = $future->finally(function() { + throw new \LogicException("finally boom"); + }); + + $state->error(new \RuntimeException("parent boom")); + + try { + await($result); + } catch (\LogicException $e) { + echo "top: ", get_class($e), " - ", $e->getMessage(), "\n"; + $prev = $e->getPrevious(); + echo "prev: ", get_class($prev), " - ", $prev->getMessage(), "\n"; + } +}); + +await($coroutine); + +?> +--EXPECT-- +top: LogicException - finally boom +prev: RuntimeException - parent boom diff --git a/tests/future/036-future_map_error_branches.phpt b/tests/future/036-future_map_error_branches.phpt new file mode 100644 index 00000000..7f7f5ff5 --- /dev/null +++ b/tests/future/036-future_map_error_branches.phpt @@ -0,0 +1,37 @@ +--TEST-- +Future: map()/catch()/finally() TypeError on non-callable argument +--FILE-- +$method(42); + echo "no-throw: $method\n"; + } catch (\TypeError $e) { + echo "$method: TypeError\n"; + } + } + + $state->complete(0); + $future->ignore(); +}); + +await($coroutine); + +?> +--EXPECT-- +map: TypeError +catch: TypeError +finally: TypeError diff --git a/tests/future/037-finally_already_completed.phpt b/tests/future/037-finally_already_completed.phpt new file mode 100644 index 00000000..428b974f --- /dev/null +++ b/tests/future/037-finally_already_completed.phpt @@ -0,0 +1,42 @@ +--TEST-- +Future::finally() - registering on an already-completed/rejected future spawns mapper immediately +--FILE-- +finally(function() { + echo "finally-ok\n"; + }); + var_dump(await($r1)); + + // 2. finally() on an already-failed future + $bad = Future::failed(new \RuntimeException("nope")); + $r2 = $bad->finally(function() { + echo "finally-err\n"; + }); + try { + await($r2); + } catch (\RuntimeException $e) { + echo "caught: ", $e->getMessage(), "\n"; + } +}); + +await($coroutine); + +?> +--EXPECT-- +finally-ok +int(10) +finally-err +caught: nope diff --git a/tests/info/003-phpinfo_async_section.phpt b/tests/info/003-phpinfo_async_section.phpt new file mode 100644 index 00000000..82dc4c50 --- /dev/null +++ b/tests/info/003-phpinfo_async_section.phpt @@ -0,0 +1,24 @@ +--TEST-- +Async: phpinfo() output contains the async module section +--FILE-- + +--EXPECT-- +bool(true) +bool(true) +bool(true) +ok diff --git a/tests/iterate/014-iterate_getIterator_throws.phpt b/tests/iterate/014-iterate_getIterator_throws.phpt new file mode 100644 index 00000000..45a647af --- /dev/null +++ b/tests/iterate/014-iterate_getIterator_throws.phpt @@ -0,0 +1,37 @@ +--TEST-- +Async\iterate: IteratorAggregate::getIterator() throwing propagates and aborts iterate() +--FILE-- +get_iterator()` call +// in Async\iterate() — when the iterator factory throws, iterate() must +// re-throw before spawning any child coroutine. + +class BadAggregate implements \IteratorAggregate +{ + public function getIterator(): \Iterator + { + throw new \LogicException("no iterator for you"); + } +} + +$coroutine = spawn(function() { + try { + iterate(new BadAggregate(), function($v, $k) { + echo "should-not-run: $v\n"; + }); + echo "no-throw\n"; + } catch (\LogicException $e) { + echo "caught: ", $e->getMessage(), "\n"; + } +}); + +await($coroutine); + +?> +--EXPECT-- +caught: no iterator for you diff --git a/tests/iterate/type_error_invalid_argument.phpt b/tests/iterate/type_error_invalid_argument.phpt new file mode 100644 index 00000000..2e2d6b64 --- /dev/null +++ b/tests/iterate/type_error_invalid_argument.phpt @@ -0,0 +1,25 @@ +--TEST-- +Async\iterate(): non-iterable argument throws TypeError +--FILE-- + null); +} catch (\TypeError $e) { + echo "caught: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECTF-- +start +caught: %A +end diff --git a/tests/pool/048-pool_healthcheck_basic.phpt b/tests/pool/048-pool_healthcheck_basic.phpt new file mode 100644 index 00000000..cd4a5011 --- /dev/null +++ b/tests/pool/048-pool_healthcheck_basic.phpt @@ -0,0 +1,45 @@ +--TEST-- +Pool: healthcheck callback periodically runs and destroys unhealthy resources +--FILE-- += 1); +var_dump(isset($seen[2]) && $seen[2] >= 1); + +$pool->close(); + +echo "done\n"; + +?> +--EXPECT-- +bool(true) +bool(true) +done diff --git a/tests/pool/049-pool_healthcheck_exception.phpt b/tests/pool/049-pool_healthcheck_exception.phpt new file mode 100644 index 00000000..6fed2207 --- /dev/null +++ b/tests/pool/049-pool_healthcheck_exception.phpt @@ -0,0 +1,49 @@ +--TEST-- +Pool: healthcheck callback throwing treats the resource as unhealthy +--FILE-- += 1); + +$pool->close(); + +echo "done\n"; + +?> +--EXPECT-- +bool(true) +bool(true) +done diff --git a/tests/pool/050-pool_factory_throws_during_min_prewarm.phpt b/tests/pool/050-pool_factory_throws_during_min_prewarm.phpt new file mode 100644 index 00000000..c1ff3d43 --- /dev/null +++ b/tests/pool/050-pool_factory_throws_during_min_prewarm.phpt @@ -0,0 +1,40 @@ +--TEST-- +Pool: factory throwing during min-size prewarm stops the pre-creation loop +--FILE-- += 2) { + throw new \RuntimeException("no more"); + } + return $n; + }, + min: 3, + max: 5, + ); + echo "constructed\n"; + var_dump($n); + $pool->close(); +} catch (\Throwable $e) { + echo "caught: " . get_class($e) . ": " . $e->getMessage() . "\n"; + var_dump($n); +} + +echo "end\n"; + +?> +--EXPECT-- +caught: RuntimeException: no more +int(2) +end diff --git a/tests/pool/051-pool_beforeAcquire_reject_destructor_throws.phpt b/tests/pool/051-pool_beforeAcquire_reject_destructor_throws.phpt new file mode 100644 index 00000000..337a04c2 --- /dev/null +++ b/tests/pool/051-pool_beforeAcquire_reject_destructor_throws.phpt @@ -0,0 +1,42 @@ +--TEST-- +Pool: beforeAcquire rejection + destructor throwing aborts the acquire loop +--FILE-- + false, + min: 1, + max: 4, +); + +spawn(function () use ($pool) { + try { + $pool->acquire(); + echo "no exception\n"; + } catch (\Throwable $e) { + echo "caught: " . get_class($e) . ": " . $e->getMessage() . "\n"; + } +}); + +echo "end\n"; + +?> +--EXPECTF-- +end +caught: RuntimeException: boom destroying #%d diff --git a/tests/pool/052-pool_strategy_report_failure_before_release.phpt b/tests/pool/052-pool_strategy_report_failure_before_release.phpt new file mode 100644 index 00000000..1e322543 --- /dev/null +++ b/tests/pool/052-pool_strategy_report_failure_before_release.phpt @@ -0,0 +1,59 @@ +--TEST-- +Pool: strategy reportFailure invoked when beforeRelease rejects (no caller-provided error) +--FILE-- +successCount++; + } + + public function reportFailure(mixed $source, \Throwable $error): void + { + $this->failureCount++; + $this->lastErrorMessage = $error->getMessage(); + $this->lastErrorClass = get_class($error); + } + + public function shouldRecover(): bool + { + return true; + } +} + +$strategy = new RecordingStrategy(); + +$pool = new Pool( + factory: fn() => 42, + beforeRelease: fn($resource) => false, +); + +$pool->setCircuitBreakerStrategy($strategy); + +spawn(function() use ($pool, $strategy) { + $resource = $pool->acquire(); + $pool->release($resource); + + echo "failureCount: {$strategy->failureCount}\n"; + echo "errorClass: {$strategy->lastErrorClass}\n"; + echo "errorMessage: {$strategy->lastErrorMessage}\n"; +}); + +echo "Done\n"; +?> +--EXPECT-- +Done +failureCount: 1 +errorClass: Exception +errorMessage: Resource validation failed diff --git a/tests/remote_future/001-future_state_transfer_complete.phpt b/tests/remote_future/001-future_state_transfer_complete.phpt new file mode 100644 index 00000000..33906ffa --- /dev/null +++ b/tests/remote_future/001-future_state_transfer_complete.phpt @@ -0,0 +1,32 @@ +--TEST-- +RemoteFuture: FutureState transferred to thread, complete() delivers result +--SKIPIF-- + +--FILE-- +complete("hello from thread"); + }); + + echo await($future) . "\n"; + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +hello from thread +Done diff --git a/tests/remote_future/002-future_state_transfer_error.phpt b/tests/remote_future/002-future_state_transfer_error.phpt new file mode 100644 index 00000000..03ecffc3 --- /dev/null +++ b/tests/remote_future/002-future_state_transfer_error.phpt @@ -0,0 +1,38 @@ +--TEST-- +RemoteFuture: FutureState transferred to thread, error() delivers exception +--SKIPIF-- + +--FILE-- +error(new \RuntimeException("thread error")); + }); + + try { + await($future); + echo "ERROR: should have thrown\n"; + } catch (\RuntimeException $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +Caught: thread error +Done diff --git a/tests/remote_future/003-future_state_transfer_complex_data.phpt b/tests/remote_future/003-future_state_transfer_complex_data.phpt new file mode 100644 index 00000000..7f4d9407 --- /dev/null +++ b/tests/remote_future/003-future_state_transfer_complex_data.phpt @@ -0,0 +1,42 @@ +--TEST-- +RemoteFuture: FutureState transferred to thread, complex data types +--SKIPIF-- + +--FILE-- +complete([ + 'name' => 'test', + 'values' => [1, 2, 3], + 'nested' => ['key' => 'value'], + ]); + }); + + $result = await($future); + echo "name: " . $result['name'] . "\n"; + echo "values: " . implode(",", $result['values']) . "\n"; + echo "nested.key: " . $result['nested']['key'] . "\n"; + + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +name: test +values: 1,2,3 +nested.key: value +Done diff --git a/tests/remote_future/004-future_state_transfer_double_complete.phpt b/tests/remote_future/004-future_state_transfer_double_complete.phpt new file mode 100644 index 00000000..2dd58fd7 --- /dev/null +++ b/tests/remote_future/004-future_state_transfer_double_complete.phpt @@ -0,0 +1,39 @@ +--TEST-- +RemoteFuture: FutureState transferred — double complete throws +--SKIPIF-- + +--FILE-- +complete("first"); + try { + $state->complete("second"); + return "ERROR: should have thrown"; + } catch (\Throwable $e) { + return "Caught: " . $e->getMessage(); + } + }); + + echo await($future) . "\n"; + echo await($thread) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +first +Caught: FutureState is already completed +Done diff --git a/tests/remote_future/005-future_state_source_loses_control.phpt b/tests/remote_future/005-future_state_source_loses_control.phpt new file mode 100644 index 00000000..d18548c7 --- /dev/null +++ b/tests/remote_future/005-future_state_source_loses_control.phpt @@ -0,0 +1,40 @@ +--TEST-- +RemoteFuture: source thread loses write access after transfer +--SKIPIF-- + +--FILE-- +complete("from thread"); + }); + + // Source thread should not be able to complete after transfer + try { + $state->complete("from main"); + } catch (\Throwable $e) { + echo "Main blocked: " . $e->getMessage() . "\n"; + } + + echo await($future) . "\n"; + await($thread); + echo "Done\n"; +}); +?> +--EXPECTF-- +Main blocked: %s +from thread +Done diff --git a/tests/remote_future/006-future_state_transfer_multiple_threads.phpt b/tests/remote_future/006-future_state_transfer_multiple_threads.phpt new file mode 100644 index 00000000..757fa9c1 --- /dev/null +++ b/tests/remote_future/006-future_state_transfer_multiple_threads.phpt @@ -0,0 +1,43 @@ +--TEST-- +RemoteFuture: FutureState cannot be transferred to multiple threads +--SKIPIF-- + +--FILE-- +complete("from t1"); + }); + + // Second transfer should throw + try { + $t2 = spawn_thread(function() use ($state) { + $state->complete("from t2"); + }); + echo "ERROR: should have thrown\n"; + } catch (\Throwable $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + echo await($future) . "\n"; + await($t1); + echo "Done\n"; +}); +?> +--EXPECT-- +Caught: FutureState cannot be transferred to multiple threads +from t1 +Done diff --git a/tests/remote_future/007-future_state_transfer_await_before_complete.phpt b/tests/remote_future/007-future_state_transfer_await_before_complete.phpt new file mode 100644 index 00000000..35d82bf5 --- /dev/null +++ b/tests/remote_future/007-future_state_transfer_await_before_complete.phpt @@ -0,0 +1,45 @@ +--TEST-- +RemoteFuture: main awaits before thread completes — proper suspend/resume +--SKIPIF-- + +--FILE-- +complete($sum); + }); + + // Main thread awaits — will suspend until thread completes + $result = await($future); + echo "Result: $result\n"; + echo "Expected: " . (99999 * 100000 / 2) . "\n"; + echo "Match: " . ($result === 99999 * 100000 / 2 ? "yes" : "no") . "\n"; + + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +Result: 4999950000 +Expected: 4999950000 +Match: yes +Done diff --git a/tests/remote_future/008-future_state_source_complete_uncaught.phpt b/tests/remote_future/008-future_state_source_complete_uncaught.phpt new file mode 100644 index 00000000..709e65a7 --- /dev/null +++ b/tests/remote_future/008-future_state_source_complete_uncaught.phpt @@ -0,0 +1,36 @@ +--TEST-- +RemoteFuture: source thread loses write access after transfer: bug +--SKIPIF-- + +--FILE-- +complete("from thread"); + }); + + // Source thread should not be able to complete after transfer + $state->complete("from main"); + + echo await($future) . "\n"; + await($thread); + echo "Done\n"; +}); +?> +--EXPECTF-- +Fatal error: Uncaught Async\AsyncException: FutureState ownership was transferred to another thread in %s:%d +Stack trace: +%A diff --git a/tests/remote_future/009-thread_crashes_without_complete.phpt b/tests/remote_future/009-thread_crashes_without_complete.phpt new file mode 100644 index 00000000..0e2268c4 --- /dev/null +++ b/tests/remote_future/009-thread_crashes_without_complete.phpt @@ -0,0 +1,44 @@ +--TEST-- +RemoteFuture: thread throws without calling complete — future must not hang +--SKIPIF-- + +--FILE-- +getMessage() . "\n"; + } + + // Future was never completed — should not hang + echo "After thread\n"; + echo "isCompleted: " . ($state->isCompleted() ? "yes" : "no") . "\n"; + $state->ignore(); + echo "Done\n"; +}); +?> +--EXPECT-- +Thread crashed: thread crashed +After thread +isCompleted: no +Done diff --git a/tests/remote_future/010-thread_no_complete.phpt b/tests/remote_future/010-thread_no_complete.phpt new file mode 100644 index 00000000..466a4799 --- /dev/null +++ b/tests/remote_future/010-thread_no_complete.phpt @@ -0,0 +1,35 @@ +--TEST-- +RemoteFuture: thread returns without calling complete — no hang +--SKIPIF-- + +--FILE-- +ignore(); + echo "Done\n"; +}); +?> +--EXPECT-- +thread done +Done diff --git a/tests/remote_future/011-future_map_chain.phpt b/tests/remote_future/011-future_map_chain.phpt new file mode 100644 index 00000000..20658752 --- /dev/null +++ b/tests/remote_future/011-future_map_chain.phpt @@ -0,0 +1,34 @@ +--TEST-- +RemoteFuture: Future.map() chain works with remote future +--SKIPIF-- + +--FILE-- +map(fn($v) => strtoupper($v)); + + $thread = spawn_thread(function() use ($state) { + $state->complete("hello world"); + }); + + echo await($mapped) . "\n"; + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +HELLO WORLD +Done diff --git a/tests/remote_future/012-is_completed_from_thread.phpt b/tests/remote_future/012-is_completed_from_thread.phpt new file mode 100644 index 00000000..161ac9f9 --- /dev/null +++ b/tests/remote_future/012-is_completed_from_thread.phpt @@ -0,0 +1,39 @@ +--TEST-- +RemoteFuture: isCompleted() from destination thread +--SKIPIF-- + +--FILE-- +isCompleted(); + $state->complete(42); + $after = $state->isCompleted(); + return [$before, $after]; + }); + + $result = await($thread); + echo "Before: " . ($result[0] ? "yes" : "no") . "\n"; + echo "After: " . ($result[1] ? "yes" : "no") . "\n"; + echo "Value: " . await($future) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +Before: no +After: yes +Value: 42 +Done diff --git a/tests/remote_future/013-multiple_awaiters.phpt b/tests/remote_future/013-multiple_awaiters.phpt new file mode 100644 index 00000000..c38ae25f --- /dev/null +++ b/tests/remote_future/013-multiple_awaiters.phpt @@ -0,0 +1,40 @@ +--TEST-- +RemoteFuture: multiple coroutines await same remote future +--SKIPIF-- + +--FILE-- + "c1:" . await($future)); + $c2 = spawn(fn() => "c2:" . await($future)); + + $thread = spawn_thread(function() use ($state) { + $state->complete("result"); + }); + + $results = [await($c1), await($c2)]; + sort($results); + echo implode("\n", $results) . "\n"; + + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +c1:result +c2:result +Done diff --git a/tests/remote_future/014-error_various_exceptions.phpt b/tests/remote_future/014-error_various_exceptions.phpt new file mode 100644 index 00000000..f4975695 --- /dev/null +++ b/tests/remote_future/014-error_various_exceptions.phpt @@ -0,0 +1,54 @@ +--TEST-- +RemoteFuture: error() with various exception types +--SKIPIF-- + +--FILE-- +error(new \RuntimeException("runtime error", 42)); + }); + + try { + await($future1); + } catch (\RuntimeException $e) { + echo "RuntimeException: " . $e->getMessage() . " code=" . $e->getCode() . "\n"; + } + await($t1); + + // Test with InvalidArgumentException + $state2 = new FutureState(); + $future2 = new Future($state2); + + $t2 = spawn_thread(function() use ($state2) { + $state2->error(new \InvalidArgumentException("bad arg")); + }); + + try { + await($future2); + } catch (\InvalidArgumentException $e) { + echo "InvalidArgumentException: " . $e->getMessage() . "\n"; + } + await($t2); + + echo "Done\n"; +}); +?> +--EXPECT-- +RuntimeException: runtime error code=42 +InvalidArgumentException: bad arg +Done diff --git a/tests/scope/043-scope_setExceptionHandler_replace.phpt b/tests/scope/043-scope_setExceptionHandler_replace.phpt new file mode 100644 index 00000000..fb98fbf8 --- /dev/null +++ b/tests/scope/043-scope_setExceptionHandler_replace.phpt @@ -0,0 +1,24 @@ +--TEST-- +Scope: setExceptionHandler() and setChildScopeExceptionHandler() replace existing handler +--FILE-- +setExceptionHandler(function () { echo "first\n"; }); +$scope->setExceptionHandler(function () { echo "second\n"; }); +$scope->setExceptionHandler(function () { echo "third\n"; }); + +$scope->setChildScopeExceptionHandler(function () { echo "child first\n"; }); +$scope->setChildScopeExceptionHandler(function () { echo "child second\n"; }); + +echo "ok\n"; + +?> +--EXPECT-- +ok diff --git a/tests/scope/044-scope_awaitAfterCancellation_not_cancelled.phpt b/tests/scope/044-scope_awaitAfterCancellation_not_cancelled.phpt new file mode 100644 index 00000000..e7b23888 --- /dev/null +++ b/tests/scope/044-scope_awaitAfterCancellation_not_cancelled.phpt @@ -0,0 +1,39 @@ +--TEST-- +Scope: awaitAfterCancellation() throws when scope is not cancelled +--FILE-- +spawn(function () { + suspend(); +}); + +$external = spawn(function () use ($scope) { + try { + $scope->awaitAfterCancellation(); + echo "no error\n"; + } catch (\Throwable $e) { + echo "caught: " . $e->getMessage() . "\n"; + } +}); + +await($external); + +$scope->dispose(); + +echo "end\n"; + +?> +--EXPECTF-- +start +caught: Attempt to await a Scope that has not been cancelled +end diff --git a/tests/scope/045-scope_awaitCompletion_self_deadlock.phpt b/tests/scope/045-scope_awaitCompletion_self_deadlock.phpt new file mode 100644 index 00000000..9c9a547c --- /dev/null +++ b/tests/scope/045-scope_awaitCompletion_self_deadlock.phpt @@ -0,0 +1,37 @@ +--TEST-- +Scope: awaitCompletion() from a coroutine that belongs to the same scope throws +--FILE-- +spawn(function () use (&$scope) { + try { + $scope->awaitCompletion(timeout(1000)); + echo "no error\n"; + } catch (\Throwable $e) { + echo "caught: " . $e->getMessage() . "\n"; + } +}); + +$external = spawn(function () use ($scope) { + $scope->awaitCompletion(timeout(2000)); +}); +await($external); + +echo "end\n"; + +?> +--EXPECTF-- +start +caught: Cannot await completion of scope from a coroutine that belongs to the same scope or its children +end diff --git a/tests/scope/046-scope_awaitAfterCancellation_self_deadlock.phpt b/tests/scope/046-scope_awaitAfterCancellation_self_deadlock.phpt new file mode 100644 index 00000000..f9f91085 --- /dev/null +++ b/tests/scope/046-scope_awaitAfterCancellation_self_deadlock.phpt @@ -0,0 +1,42 @@ +--TEST-- +Scope: awaitAfterCancellation() from a coroutine that belongs to the same scope throws +--FILE-- +asNotSafely(); + +$inner = $scope->spawn(function () use (&$scope) { + // Wait until the scope is cancelled from outside, then trigger self-deadlock path. + try { + suspend(); + } catch (\Async\AsyncCancellation $e) { + try { + $scope->awaitAfterCancellation(); + echo "no error\n"; + } catch (\Throwable $e) { + echo "caught: " . $e->getMessage() . "\n"; + } + } +}); + +suspend(); +$scope->cancel(new \Async\AsyncCancellation("bye")); +suspend(); +suspend(); + +echo "end\n"; + +?> +--EXPECTF-- +start +caught: Cannot await completion of scope from a coroutine that belongs to the same scope or its children +end diff --git a/tests/scope/047-scope_exception_handler_fires.phpt b/tests/scope/047-scope_exception_handler_fires.phpt new file mode 100644 index 00000000..24aca550 --- /dev/null +++ b/tests/scope/047-scope_exception_handler_fires.phpt @@ -0,0 +1,38 @@ +--TEST-- +Scope: setExceptionHandler() handler actually fires and suppresses coroutine exception +--FILE-- +asNotSafely(); + +$scope->setExceptionHandler(function (Scope $s, \Async\Coroutine $c, \Throwable $e) { + echo "handler got: " . $e->getMessage() . "\n"; +}); + +$scope->spawn(function () { + throw new \RuntimeException("boom"); +}); + +// Let the coroutine throw and the handler run. +suspend(); +suspend(); + +echo "scope finished: " . ($scope->isFinished() ? "true" : "false") . "\n"; +echo "end\n"; + +?> +--EXPECTF-- +start +handler got: boom +scope finished: true +end diff --git a/tests/scope/048-scope_child_exception_handler_fires.phpt b/tests/scope/048-scope_child_exception_handler_fires.phpt new file mode 100644 index 00000000..e5c50d2d --- /dev/null +++ b/tests/scope/048-scope_child_exception_handler_fires.phpt @@ -0,0 +1,39 @@ +--TEST-- +Scope: setChildScopeExceptionHandler() handler fires when a child scope coroutine throws +--FILE-- +asNotSafely(); + +$parent->setChildScopeExceptionHandler(function (Scope $scope, \Async\Coroutine $c, \Throwable $e) { + echo "parent child-handler got: " . $e->getMessage() . "\n"; +}); + +$child = Scope::inherit($parent)->asNotSafely(); + +$child->spawn(function () { + throw new \RuntimeException("child boom"); +}); + +// Let the child coroutine throw; exception should bubble to parent's child-handler. +suspend(); +suspend(); + +echo "parent finished: " . ($parent->isFinished() ? "true" : "false") . "\n"; +echo "end\n"; + +?> +--EXPECTF-- +start +parent child-handler got: child boom +parent finished: true +end diff --git a/tests/scope/049-scope_awaitAfterCancellation_error_handler_fires.phpt b/tests/scope/049-scope_awaitAfterCancellation_error_handler_fires.phpt new file mode 100644 index 00000000..44b0bd6c --- /dev/null +++ b/tests/scope/049-scope_awaitAfterCancellation_error_handler_fires.phpt @@ -0,0 +1,48 @@ +--TEST-- +Scope: awaitAfterCancellation() error handler is invoked when a coroutine finishes with an exception +--FILE-- +asNotSafely(); + +$scope->spawn(function () { + try { + while (true) { + suspend(); + } + } catch (\Async\AsyncCancellation $e) { + throw new \RuntimeException("after-cancel error"); + } +}); + +$external = spawn(function () use ($scope) { + suspend(); + $scope->cancel(new \Async\AsyncCancellation("bye")); + + $scope->awaitAfterCancellation(function (\Throwable $e, Scope $s) { + echo "error handler: " . $e->getMessage() . "\n"; + }); + + echo "external done\n"; +}); + +await($external); + +echo "end\n"; + +?> +--EXPECTF-- +start +error handler: after-cancel error +external done +end diff --git a/tests/scope/050-scope_awaitAfterCancellation_no_handler_propagates.phpt b/tests/scope/050-scope_awaitAfterCancellation_no_handler_propagates.phpt new file mode 100644 index 00000000..1ee6fdf8 --- /dev/null +++ b/tests/scope/050-scope_awaitAfterCancellation_no_handler_propagates.phpt @@ -0,0 +1,47 @@ +--TEST-- +Scope: awaitAfterCancellation() without an error handler propagates the coroutine exception +--FILE-- +asNotSafely(); + +$scope->spawn(function () { + try { + while (true) { + suspend(); + } + } catch (\Async\AsyncCancellation $e) { + throw new \RuntimeException("propagated error"); + } +}); + +$external = spawn(function () use ($scope) { + suspend(); + $scope->cancel(new \Async\AsyncCancellation("bye")); + + try { + $scope->awaitAfterCancellation(); + echo "no exception\n"; + } catch (\Throwable $e) { + echo "propagated: " . get_class($e) . ": " . $e->getMessage() . "\n"; + } +}); + +await($external); + +echo "end\n"; + +?> +--EXPECTF-- +start +propagated: RuntimeException: propagated error +end diff --git a/tests/scope/051-scope_awaitCompletion_deadlock_from_grandchild.phpt b/tests/scope/051-scope_awaitCompletion_deadlock_from_grandchild.phpt new file mode 100644 index 00000000..e1c97e8a --- /dev/null +++ b/tests/scope/051-scope_awaitCompletion_deadlock_from_grandchild.phpt @@ -0,0 +1,36 @@ +--TEST-- +Scope: awaitCompletion() called on grandparent from a grandchild-scope coroutine throws +--FILE-- +spawn(function () use ($grand) { + try { + $grand->awaitCompletion(timeout(1000)); + echo "no error\n"; + } catch (\Throwable $e) { + echo "caught: " . $e->getMessage() . "\n"; + } +}); + +suspend(); + +echo "end\n"; + +?> +--EXPECTF-- +start +caught: Cannot await completion of scope from a coroutine that belongs to the same scope or its children +end diff --git a/tests/scope/052-scope_gc_with_context_values.phpt b/tests/scope/052-scope_gc_with_context_values.phpt new file mode 100644 index 00000000..5a9a8d0a --- /dev/null +++ b/tests/scope/052-scope_gc_with_context_values.phpt @@ -0,0 +1,38 @@ +--TEST-- +Scope: gc_get handler walks scope context values and object keys +--FILE-- +values and context->keys. + +echo "start\n"; + +$scope = new Scope(); + +$scope->spawn(function () { + $ctx = current_context(); + $ctx->set("string_key", "value1"); + $ctx->set("number_key", 42); + + // object keys + $objKey = new stdClass(); + $ctx->set($objKey, "obj_value"); + + suspend(); +}); + +suspend(); + +gc_collect_cycles(); + +echo "end\n"; + +?> +--EXPECT-- +start +end diff --git a/tests/scope/053-scope_destroy_with_active_coroutines.phpt b/tests/scope/053-scope_destroy_with_active_coroutines.phpt new file mode 100644 index 00000000..a58754ef --- /dev/null +++ b/tests/scope/053-scope_destroy_with_active_coroutines.phpt @@ -0,0 +1,46 @@ +--TEST-- +Scope: destroying scope object while coroutines are still active cancels them +--FILE-- +asNotSafely(); + $scope->spawn(function () { + try { + while (true) { + suspend(); + } + } catch (\Async\AsyncCancellation $e) { + echo "inner got: " . $e->getMessage() . "\n"; + } + }); + suspend(); // let the inner coroutine actually start running + // $scope goes out of scope when this function returns. + // Its destructor must cancel the running coroutine. +} + +make_scope_with_coroutine(); + +// Let the scheduler dispatch the cancellation before exiting. +suspend(); +suspend(); + +echo "end\n"; + +?> +--EXPECTF-- +start +inner got: Scope is being disposed due to object destruction +end + diff --git a/tests/scope/054-scope_awaitAfterCancellation_error_handler_throws.phpt b/tests/scope/054-scope_awaitAfterCancellation_error_handler_throws.phpt new file mode 100644 index 00000000..9a89f5b7 --- /dev/null +++ b/tests/scope/054-scope_awaitAfterCancellation_error_handler_throws.phpt @@ -0,0 +1,50 @@ +--TEST-- +Scope: awaitAfterCancellation() error handler may throw and its exception propagates +--FILE-- +asNotSafely(); + +$scope->spawn(function () { + try { + while (true) { + suspend(); + } + } catch (\Async\AsyncCancellation $e) { + throw new \RuntimeException("initial"); + } +}); + +$external = spawn(function () use ($scope) { + suspend(); + $scope->cancel(new \Async\AsyncCancellation("bye")); + + try { + $scope->awaitAfterCancellation(function (\Throwable $e, Scope $s) { + throw new \LogicException("handler replacement: " . $e->getMessage()); + }); + echo "no exception\n"; + } catch (\Throwable $e) { + echo "caught: " . get_class($e) . ": " . $e->getMessage() . "\n"; + } +}); + +await($external); + +echo "end\n"; + +?> +--EXPECTF-- +start +caught: LogicException: handler replacement: initial +end diff --git a/tests/scope/055-scope_finally_on_disposed_scope.phpt b/tests/scope/055-scope_finally_on_disposed_scope.phpt new file mode 100644 index 00000000..a686e5df --- /dev/null +++ b/tests/scope/055-scope_finally_on_disposed_scope.phpt @@ -0,0 +1,42 @@ +--TEST-- +Scope: finally() invoked on a scope whose internal scope has already been released runs the callable immediately +--FILE-- +s->finally(function (Scope $x) { + echo "late finally fired\n"; + }); + echo "destruct returned\n"; + } +} + +function setup(): Holder { + $scope = new Scope(); + $holder = new Holder($scope); + // Create a cycle so GC is involved; also register a finally on the scope + // that references the holder to ensure the cycle is real. + $scope->finally(function () use ($holder) {}); + return $holder; +} + +$h = setup(); +gc_collect_cycles(); +unset($h); + +echo "end\n"; + +?> +--EXPECT-- +end +destruct calls finally +late finally fired +destruct returned diff --git a/tests/signal/004-signal_with_resolved_cancellation.phpt b/tests/signal/004-signal_with_resolved_cancellation.phpt new file mode 100644 index 00000000..76c22809 --- /dev/null +++ b/tests/signal/004-signal_with_resolved_cancellation.phpt @@ -0,0 +1,40 @@ +--TEST-- +Async\signal(): an already-resolved cancellation argument returns an immediately-rejected Future +--FILE-- +complete("ready"); + +$cancel = new Future($state); +$cancel->ignore(); + +$result = signal(Signal::SIGINT, $cancel); + +try { + await($result); + echo "no exception\n"; +} catch (\Async\AsyncCancellation $e) { + echo "caught: " . $e->getMessage() . "\n"; +} + +echo "end\n"; + +?> +--EXPECT-- +start +caught: Signal wait cancelled +end diff --git a/tests/sleep/003-delay_zero_immediate.phpt b/tests/sleep/003-delay_zero_immediate.phpt new file mode 100644 index 00000000..0965ad1e --- /dev/null +++ b/tests/sleep/003-delay_zero_immediate.phpt @@ -0,0 +1,30 @@ +--TEST-- +Async\delay(0): enqueues the current coroutine without a timer +--FILE-- + +--EXPECT-- +Array +( + [0] => before + [1] => after +) diff --git a/tests/task_group/035-task_group_all_synchronous_reject.phpt b/tests/task_group/035-task_group_all_synchronous_reject.phpt new file mode 100644 index 00000000..db92a065 --- /dev/null +++ b/tests/task_group/035-task_group_all_synchronous_reject.phpt @@ -0,0 +1,48 @@ +--TEST-- +TaskGroup: all() called after tasks already failed synchronously rejects via CompositeException +--FILE-- +spawn(function() { throw new \RuntimeException("sync-fail-1"); }); + $group->spawn(function() { throw new \LogicException("sync-fail-2"); }); + + $group->seal(); + + // Let the spawned tasks run to completion before calling all(). + suspend(); + suspend(); + + // Using an intermediate $future variable used to crash at shutdown: + // the sync-settled path forgot to detach the waiter from the group's + // waiter_events[] vector, so task_group_free_object() double-disposed + // the already-wrapped future. Now it must work cleanly. + $future = $group->all(); + try { + $future->await(); + echo "no-throw\n"; + } catch (CompositeException $e) { + echo "count: ", count($e->getExceptions()), "\n"; + foreach ($e->getExceptions() as $err) { + echo get_class($err), ": ", $err->getMessage(), "\n"; + } + } +}); + +?> +--EXPECT-- +count: 2 +RuntimeException: sync-fail-1 +LogicException: sync-fail-2 diff --git a/tests/task_group/035-task_group_gc_traversal_all_states.phpt b/tests/task_group/035-task_group_gc_traversal_all_states.phpt new file mode 100644 index 00000000..a1d231bc --- /dev/null +++ b/tests/task_group/035-task_group_gc_traversal_all_states.phpt @@ -0,0 +1,50 @@ +--TEST-- +TaskGroup: gc_get handler walks tasks in PENDING / RUNNING / ERROR states +--FILE-- +spawn(function () { + throw new \RuntimeException("first"); +}); + +// 2. A short suspend-then-finish task — RUNNING for a few ticks. +$group->spawn(function () { + suspend(); + suspend(); +}); + +// 3. Concurrency=1 means extra tasks are queued as TASK_STATE_PENDING. +$group->spawn(function () {}); +$group->spawn(function () {}); + +// Yield once: task #1 runs and throws, task #2 starts and suspends. +suspend(); + +// Force GC while task #2 is in RUNNING and tasks #3/#4 are PENDING. +gc_collect_cycles(); + +// Drain the rest. +suspend(); +suspend(); +suspend(); + +echo "end\n"; + +?> +--EXPECTF-- +start +end diff --git a/tests/task_group/036-task_group_race_synchronous_error.phpt b/tests/task_group/036-task_group_race_synchronous_error.phpt new file mode 100644 index 00000000..1ee800c5 --- /dev/null +++ b/tests/task_group/036-task_group_race_synchronous_error.phpt @@ -0,0 +1,36 @@ +--TEST-- +TaskGroup: race() called after first task already failed synchronously rejects immediately +--FILE-- +spawn(function() { throw new \RuntimeException("first-fail"); }); + $group->spawn(function() { return "second"; }); + + $group->seal(); + + // Let tasks run to completion before calling race(). + suspend(); + suspend(); + + try { + $group->race()->await(); + echo "no-throw\n"; + } catch (\RuntimeException $e) { + echo "caught: ", $e->getMessage(), "\n"; + } +}); + +?> +--EXPECT-- +caught: first-fail diff --git a/tests/task_group/037-task_group_any_synchronous_all_failed.phpt b/tests/task_group/037-task_group_any_synchronous_all_failed.phpt new file mode 100644 index 00000000..33549fad --- /dev/null +++ b/tests/task_group/037-task_group_any_synchronous_all_failed.phpt @@ -0,0 +1,35 @@ +--TEST-- +TaskGroup: any() called after all tasks already failed synchronously rejects with CompositeException +--FILE-- +spawn(function() { throw new \RuntimeException("fail1"); }); + $group->spawn(function() { throw new \LogicException("fail2"); }); + $group->seal(); + + // Let tasks run to completion before calling any(). + suspend(); + suspend(); + + try { + $group->any()->await(); + echo "no-throw\n"; + } catch (CompositeException $e) { + echo "count: ", count($e->getExceptions()), "\n"; + } +}); + +?> +--EXPECT-- +count: 2 diff --git a/tests/task_group/038-task_group_error_branches.phpt b/tests/task_group/038-task_group_error_branches.phpt new file mode 100644 index 00000000..0c595cff --- /dev/null +++ b/tests/task_group/038-task_group_error_branches.phpt @@ -0,0 +1,43 @@ +--TEST-- +TaskGroup: small error-branch surface (empty any, negative concurrency, duplicate integer key, spawn on completed) +--FILE-- +any(); + echo "no-throw-empty-any\n"; + } catch (\Async\AsyncException $e) { + echo "empty-any: ", $e->getMessage(), "\n"; + } + + // 2. Negative concurrency argument to __construct (L1260-1262) + try { + new TaskGroup(-1); + echo "no-throw-concurrency\n"; + } catch (\ValueError $e) { + echo "negative concurrency: ", $e->getMessage(), "\n"; + } + + // 3. Duplicate integer key via spawnWithKey (L1322) + $g3 = new TaskGroup(); + $g3->spawnWithKey(7, function() { return 'a'; }); + try { + $g3->spawnWithKey(7, function() { return 'b'; }); + echo "no-throw-dup-int\n"; + } catch (\Async\AsyncException $e) { + echo "dup int: ", $e->getMessage(), "\n"; + } + $g3->cancel(); +}); + +?> +--EXPECT-- +empty-any: Cannot call any() on an empty TaskGroup +negative concurrency: Async\TaskGroup::__construct(): Argument #1 ($concurrency) must be greater than or equal to 0 +dup int: Duplicate key 7 in TaskGroup diff --git a/tests/task_group/039-task_group_direct_getIterator.phpt b/tests/task_group/039-task_group_direct_getIterator.phpt new file mode 100644 index 00000000..cf6b086f --- /dev/null +++ b/tests/task_group/039-task_group_direct_getIterator.phpt @@ -0,0 +1,22 @@ +--TEST-- +TaskGroup: calling getIterator() directly throws "invalid state" (normal foreach goes through the get_iterator handler) +--FILE-- +getIterator(); + echo "no-throw\n"; +} catch (\Error $e) { + echo "caught: ", $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +caught: An object of class Async\TaskGroup is not a traversable object in an invalid state diff --git a/tests/thread/001-spawn_thread_basic.phpt b/tests/thread/001-spawn_thread_basic.phpt new file mode 100644 index 00000000..ae182168 --- /dev/null +++ b/tests/thread/001-spawn_thread_basic.phpt @@ -0,0 +1,32 @@ +--TEST-- +spawn_thread() - basic thread spawn with closure +--SKIPIF-- + +--FILE-- + +--EXPECT-- +start +end +thread executed +thread completed diff --git a/tests/thread/002-spawn_thread_return_value.phpt b/tests/thread/002-spawn_thread_return_value.phpt new file mode 100644 index 00000000..e8751f1f --- /dev/null +++ b/tests/thread/002-spawn_thread_return_value.phpt @@ -0,0 +1,25 @@ +--TEST-- +spawn_thread() - thread returns a value via await +--SKIPIF-- + +--FILE-- + +--EXPECT-- +result: 42 diff --git a/tests/thread/003-spawn_thread_exception.phpt b/tests/thread/003-spawn_thread_exception.phpt new file mode 100644 index 00000000..5f7a9ce0 --- /dev/null +++ b/tests/thread/003-spawn_thread_exception.phpt @@ -0,0 +1,36 @@ +--TEST-- +spawn_thread() - exception in thread propagates to parent +--SKIPIF-- + +--FILE-- +getMessage() . "\n"; + echo "remote class: " . $e->getRemoteClass() . "\n"; + $remote = $e->getRemoteException(); + if ($remote !== null) { + echo "remote message: " . $remote->getMessage() . "\n"; + } + } +}); +?> +--EXPECT-- +caught: thread error +remote class: RuntimeException +remote message: thread error diff --git a/tests/thread/004-spawn_thread_multiple.phpt b/tests/thread/004-spawn_thread_multiple.phpt new file mode 100644 index 00000000..04d00d20 --- /dev/null +++ b/tests/thread/004-spawn_thread_multiple.phpt @@ -0,0 +1,40 @@ +--TEST-- +spawn_thread() - multiple threads run concurrently +--SKIPIF-- + +--FILE-- + +--EXPECT-- +thread-1 +thread-2 +thread-3 diff --git a/tests/thread/005-spawn_thread_isolated_globals.phpt b/tests/thread/005-spawn_thread_isolated_globals.phpt new file mode 100644 index 00000000..dd11af6d --- /dev/null +++ b/tests/thread/005-spawn_thread_isolated_globals.phpt @@ -0,0 +1,44 @@ +--TEST-- +spawn_thread() - each thread has isolated globals +--SKIPIF-- + +--FILE-- + +--EXPECT-- +globals isolated +parent globals unaffected +parent value: parent_value diff --git a/tests/thread/006-spawn_thread_internal_functions.phpt b/tests/thread/006-spawn_thread_internal_functions.phpt new file mode 100644 index 00000000..9309f289 --- /dev/null +++ b/tests/thread/006-spawn_thread_internal_functions.phpt @@ -0,0 +1,31 @@ +--TEST-- +spawn_thread() - internal PHP functions available in child thread +--SKIPIF-- + +--FILE-- + +--EXPECT-- +5 +WORLD +1,2,3 +done diff --git a/tests/thread/007-spawn_thread_closure_captures_nothing.phpt b/tests/thread/007-spawn_thread_closure_captures_nothing.phpt new file mode 100644 index 00000000..46604e33 --- /dev/null +++ b/tests/thread/007-spawn_thread_closure_captures_nothing.phpt @@ -0,0 +1,28 @@ +--TEST-- +spawn_thread() - closure must not capture parent variables by reference +--SKIPIF-- + +--FILE-- + +--EXPECT-- +no captures works +result: true diff --git a/tests/thread/008-spawn_thread_non_zts_error.phpt b/tests/thread/008-spawn_thread_non_zts_error.phpt new file mode 100644 index 00000000..740ed616 --- /dev/null +++ b/tests/thread/008-spawn_thread_non_zts_error.phpt @@ -0,0 +1,30 @@ +--TEST-- +spawn_thread() - returns error code on non-ZTS build +--SKIPIF-- + +--FILE-- +getMessage() . "\n"; + } +}); +?> +--EXPECTF-- +%s diff --git a/tests/thread/009-spawn_thread_bootloader.phpt b/tests/thread/009-spawn_thread_bootloader.phpt new file mode 100644 index 00000000..b142798d --- /dev/null +++ b/tests/thread/009-spawn_thread_bootloader.phpt @@ -0,0 +1,40 @@ +--TEST-- +spawn_thread() - bootloader closure runs before task +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bootloader executed +autoloaders: 1 +task executed +done diff --git a/tests/thread/010-spawn_thread_global_context.phpt b/tests/thread/010-spawn_thread_global_context.phpt new file mode 100644 index 00000000..603a4580 --- /dev/null +++ b/tests/thread/010-spawn_thread_global_context.phpt @@ -0,0 +1,24 @@ +--TEST-- +spawn_thread() - works without coroutine wrapper (global context) +--SKIPIF-- + +--FILE-- + +--EXPECT-- +hello from thread +done diff --git a/tests/thread/011-spawn_thread_return_string.phpt b/tests/thread/011-spawn_thread_return_string.phpt new file mode 100644 index 00000000..ef912141 --- /dev/null +++ b/tests/thread/011-spawn_thread_return_string.phpt @@ -0,0 +1,25 @@ +--TEST-- +spawn_thread() - return string value +--SKIPIF-- + +--FILE-- + +--EXPECT-- +string: hello world diff --git a/tests/thread/012-spawn_thread_return_array.phpt b/tests/thread/012-spawn_thread_return_array.phpt new file mode 100644 index 00000000..3a3b6e4f --- /dev/null +++ b/tests/thread/012-spawn_thread_return_array.phpt @@ -0,0 +1,45 @@ +--TEST-- +spawn_thread() - return array with mixed keys +--SKIPIF-- + +--FILE-- + 'test', + 'values' => [1, 2, 3], + 42 => 'numeric key', + 'nested' => ['a' => 'b', 'c' => [true, false, null]], + ]; + }); + + $result = await($thread); + echo $result['name'] . "\n"; + echo implode(',', $result['values']) . "\n"; + echo $result[42] . "\n"; + echo $result['nested']['a'] . "\n"; + var_dump($result['nested']['c']); +}); +?> +--EXPECT-- +test +1,2,3 +numeric key +b +array(3) { + [0]=> + bool(true) + [1]=> + bool(false) + [2]=> + NULL +} diff --git a/tests/thread/013-spawn_thread_return_scalars.phpt b/tests/thread/013-spawn_thread_return_scalars.phpt new file mode 100644 index 00000000..f540edf2 --- /dev/null +++ b/tests/thread/013-spawn_thread_return_scalars.phpt @@ -0,0 +1,38 @@ +--TEST-- +spawn_thread() - return all scalar types +--SKIPIF-- + +--FILE-- + +--EXPECT-- +bool(true) +bool(false) +NULL +float(3.14) +int(0) +string(0) "" +int(9223372036854775807) diff --git a/tests/thread/014-spawn_thread_return_object.phpt b/tests/thread/014-spawn_thread_return_object.phpt new file mode 100644 index 00000000..f2aeb0b9 --- /dev/null +++ b/tests/thread/014-spawn_thread_return_object.phpt @@ -0,0 +1,33 @@ +--TEST-- +spawn_thread() - stdClass with dynamic properties throws ThreadTransferException +--SKIPIF-- + +--FILE-- +name = "test"; + return $obj; + }); + + try { + await($thread); + echo "ERROR: should not reach here\n"; + } catch (\Async\RemoteException $e) { + echo "caught: dynamic properties not supported\n"; + } catch (\Async\ThreadTransferException $e) { + echo "caught: dynamic properties not supported\n"; + } +}); +?> +--EXPECTF-- +%Acaught: dynamic properties not supported diff --git a/tests/thread/015-spawn_thread_captured_vars.phpt b/tests/thread/015-spawn_thread_captured_vars.phpt new file mode 100644 index 00000000..838c1979 --- /dev/null +++ b/tests/thread/015-spawn_thread_captured_vars.phpt @@ -0,0 +1,37 @@ +--TEST-- +spawn_thread() - closure with captured variables (use) +--SKIPIF-- + +--FILE-- + $greeting . ' world', + 'result' => 5 * $multiplier, + 'items' => $data, + ]; + }); + + $result = await($thread); + echo $result['msg'] . "\n"; + echo $result['result'] . "\n"; + echo implode(',', $result['items']) . "\n"; +}); +?> +--EXPECT-- +hello world +50 +a,b,c diff --git a/tests/thread/016-spawn_thread_bootloader_exception.phpt b/tests/thread/016-spawn_thread_bootloader_exception.phpt new file mode 100644 index 00000000..ade2f61d --- /dev/null +++ b/tests/thread/016-spawn_thread_bootloader_exception.phpt @@ -0,0 +1,38 @@ +--TEST-- +spawn_thread() - bootloader throws exception +--SKIPIF-- + +--FILE-- +getMessage() . "\n"; + echo "remote class: " . $e->getRemoteClass() . "\n"; + } +}); +?> +--EXPECT-- +caught: bootloader failed +remote class: RuntimeException diff --git a/tests/thread/017-spawn_thread_bailout_exit.phpt b/tests/thread/017-spawn_thread_bailout_exit.phpt new file mode 100644 index 00000000..74111351 --- /dev/null +++ b/tests/thread/017-spawn_thread_bailout_exit.phpt @@ -0,0 +1,31 @@ +--TEST-- +spawn_thread() - exit() in child thread produces ThreadTransferException +--SKIPIF-- + +--FILE-- +getMessage()) > 0 ? "yes" : "no") . "\n"; + } +}); +?> +--EXPECT-- +caught transfer exception +message not empty: yes diff --git a/tests/thread/018-spawn_thread_depth_limit.phpt b/tests/thread/018-spawn_thread_depth_limit.phpt new file mode 100644 index 00000000..bc296073 --- /dev/null +++ b/tests/thread/018-spawn_thread_depth_limit.phpt @@ -0,0 +1,35 @@ +--TEST-- +spawn_thread() - deeply nested array exceeds transfer depth limit +--SKIPIF-- + +--FILE-- + +--EXPECTF-- +%Acaught depth limit diff --git a/tests/thread/019-spawn_thread_transfer_empty_array.phpt b/tests/thread/019-spawn_thread_transfer_empty_array.phpt new file mode 100644 index 00000000..6148940a --- /dev/null +++ b/tests/thread/019-spawn_thread_transfer_empty_array.phpt @@ -0,0 +1,24 @@ +--TEST-- +spawn_thread() - transfer empty array +--SKIPIF-- + +--FILE-- + +--EXPECT-- +array(0) { +} diff --git a/tests/thread/020-spawn_thread_transfer_nested_objects.phpt b/tests/thread/020-spawn_thread_transfer_nested_objects.phpt new file mode 100644 index 00000000..92f39908 --- /dev/null +++ b/tests/thread/020-spawn_thread_transfer_nested_objects.phpt @@ -0,0 +1,33 @@ +--TEST-- +spawn_thread() - nested stdClass objects rejected (dynamic properties) +--SKIPIF-- + +--FILE-- +name = "test"; + return ['data' => $obj]; + }); + + try { + await($thread); + echo "ERROR: should not reach here\n"; + } catch (\Async\RemoteException $e) { + echo "caught: dynamic properties not transferable\n"; + } catch (\Async\ThreadTransferException $e) { + echo "caught: dynamic properties not transferable\n"; + } +}); +?> +--EXPECTF-- +%Acaught: dynamic properties not transferable diff --git a/tests/thread/021-spawn_thread_transfer_duplicate_refs.phpt b/tests/thread/021-spawn_thread_transfer_duplicate_refs.phpt new file mode 100644 index 00000000..36081387 --- /dev/null +++ b/tests/thread/021-spawn_thread_transfer_duplicate_refs.phpt @@ -0,0 +1,30 @@ +--TEST-- +spawn_thread() - same array referenced twice preserves identity +--SKIPIF-- + +--FILE-- + $shared, + 'b' => $shared, + ]; + })); + + echo implode(',', $result['a']) . "\n"; + echo implode(',', $result['b']) . "\n"; +}); +?> +--EXPECT-- +1,2,3 +1,2,3 diff --git a/tests/thread/022-spawn_thread_transfer_duplicate_strings.phpt b/tests/thread/022-spawn_thread_transfer_duplicate_strings.phpt new file mode 100644 index 00000000..222d499b --- /dev/null +++ b/tests/thread/022-spawn_thread_transfer_duplicate_strings.phpt @@ -0,0 +1,29 @@ +--TEST-- +spawn_thread() - same string in multiple places +--SKIPIF-- + +--FILE-- + $str, 'b' => $str, 'c' => [$str]]; + })); + + echo strlen($result['a']) . "\n"; + echo ($result['a'] === $result['b']) ? "equal\n" : "not equal\n"; + echo ($result['c'][0] === $result['a']) ? "equal\n" : "not equal\n"; +}); +?> +--EXPECT-- +100 +equal +equal diff --git a/tests/thread/023-spawn_thread_transfer_cyclic_array.phpt b/tests/thread/023-spawn_thread_transfer_cyclic_array.phpt new file mode 100644 index 00000000..e9eb7d28 --- /dev/null +++ b/tests/thread/023-spawn_thread_transfer_cyclic_array.phpt @@ -0,0 +1,35 @@ +--TEST-- +spawn_thread() - reference in return value throws error +--SKIPIF-- + +--FILE-- + +--EXPECTF-- +%Acaught: references not transferable diff --git a/tests/thread/024-spawn_thread_transfer_cyclic_objects.phpt b/tests/thread/024-spawn_thread_transfer_cyclic_objects.phpt new file mode 100644 index 00000000..5cf35129 --- /dev/null +++ b/tests/thread/024-spawn_thread_transfer_cyclic_objects.phpt @@ -0,0 +1,33 @@ +--TEST-- +spawn_thread() - stdClass with cyclic refs rejected (dynamic properties) +--SKIPIF-- + +--FILE-- +name = 'A'; + return $a; + }); + + try { + await($thread); + echo "ERROR: should not reach here\n"; + } catch (\Async\RemoteException $e) { + echo "caught: dynamic properties\n"; + } catch (\Async\ThreadTransferException $e) { + echo "caught: dynamic properties\n"; + } +}); +?> +--EXPECTF-- +%Acaught: dynamic properties diff --git a/tests/thread/025-spawn_thread_captured_complex_vars.phpt b/tests/thread/025-spawn_thread_captured_complex_vars.phpt new file mode 100644 index 00000000..d74f996a --- /dev/null +++ b/tests/thread/025-spawn_thread_captured_complex_vars.phpt @@ -0,0 +1,37 @@ +--TEST-- +spawn_thread() - closure captures complex types (array, float, nested) +--SKIPIF-- + +--FILE-- + 'localhost', 'port' => 8080]; + $factor = 2.5; + $nested = ['level1' => ['level2' => 'deep']]; + + $thread = spawn_thread(function() use ($config, $factor, $nested) { + return [ + 'host' => $config['host'], + 'scaled' => $config['port'] * $factor, + 'deep' => $nested['level1']['level2'], + ]; + }); + + $result = await($thread); + echo $result['host'] . "\n"; + echo $result['scaled'] . "\n"; + echo $result['deep'] . "\n"; +}); +?> +--EXPECT-- +localhost +20200 +deep diff --git a/tests/thread/026-spawn_thread_exception_with_code.phpt b/tests/thread/026-spawn_thread_exception_with_code.phpt new file mode 100644 index 00000000..8bdeeb89 --- /dev/null +++ b/tests/thread/026-spawn_thread_exception_with_code.phpt @@ -0,0 +1,43 @@ +--TEST-- +spawn_thread() - exception with code and previous +--SKIPIF-- + +--FILE-- +getMessage() . "\n"; + echo "code: " . $e->getCode() . "\n"; + echo "remote class: " . $e->getRemoteClass() . "\n"; + + $remote = $e->getRemoteException(); + echo "remote message: " . $remote->getMessage() . "\n"; + echo "remote code: " . $remote->getCode() . "\n"; + + $prev = $remote->getPrevious(); + echo "previous: " . ($prev ? $prev->getMessage() : "none") . "\n"; + } +}); +?> +--EXPECT-- +message: operation failed +code: 500 +remote class: RuntimeException +remote message: operation failed +remote code: 500 +previous: root cause diff --git a/tests/thread/027-spawn_thread_void_return.phpt b/tests/thread/027-spawn_thread_void_return.phpt new file mode 100644 index 00000000..988e86e1 --- /dev/null +++ b/tests/thread/027-spawn_thread_void_return.phpt @@ -0,0 +1,25 @@ +--TEST-- +spawn_thread() - closure with no return value +--SKIPIF-- + +--FILE-- + +--EXPECT-- +NULL diff --git a/tests/thread/028-spawn_thread_call_user_function.phpt b/tests/thread/028-spawn_thread_call_user_function.phpt new file mode 100644 index 00000000..6e37674c --- /dev/null +++ b/tests/thread/028-spawn_thread_call_user_function.phpt @@ -0,0 +1,37 @@ +--TEST-- +spawn_thread() - calling built-in functions inside closure +--SKIPIF-- + +--FILE-- + +--EXPECT-- +1,1,3,4,5,9 +ABABAB +2,4,6 diff --git a/tests/thread/029-spawn_thread_static_vars_isolation.phpt b/tests/thread/029-spawn_thread_static_vars_isolation.phpt new file mode 100644 index 00000000..240f11bb --- /dev/null +++ b/tests/thread/029-spawn_thread_static_vars_isolation.phpt @@ -0,0 +1,38 @@ +--TEST-- +spawn_thread() - static variables in closure are isolated per thread +--SKIPIF-- + +--FILE-- + +--EXPECT-- +thread1: 1,2,3 +thread2: 1,2 diff --git a/tests/thread/030-spawn_thread_sequential_stress.phpt b/tests/thread/030-spawn_thread_sequential_stress.phpt new file mode 100644 index 00000000..6c9d989d --- /dev/null +++ b/tests/thread/030-spawn_thread_sequential_stress.phpt @@ -0,0 +1,37 @@ +--TEST-- +spawn_thread() - many sequential threads with string returns +--SKIPIF-- + +--FILE-- + +--EXPECT-- +thread-0: +thread-1:x +thread-2:xx +thread-3:xxx +thread-4:xxxx +thread-5:xxxxx +thread-6:xxxxxx +thread-7:xxxxxxx +thread-8:xxxxxxxx +thread-9:xxxxxxxxx diff --git a/tests/thread/031-spawn_thread_concurrent_stress.phpt b/tests/thread/031-spawn_thread_concurrent_stress.phpt new file mode 100644 index 00000000..771d7283 --- /dev/null +++ b/tests/thread/031-spawn_thread_concurrent_stress.phpt @@ -0,0 +1,32 @@ +--TEST-- +spawn_thread() - many concurrent threads +--SKIPIF-- + +--FILE-- + +--EXPECT-- +0,1,4,9,16,25,36,49 diff --git a/tests/thread/032-spawn_thread_nested_closure.phpt b/tests/thread/032-spawn_thread_nested_closure.phpt new file mode 100644 index 00000000..5bc0d01a --- /dev/null +++ b/tests/thread/032-spawn_thread_nested_closure.phpt @@ -0,0 +1,28 @@ +--TEST-- +spawn_thread() - nested closures and array_map inside thread +--SKIPIF-- + +--FILE-- + +--EXPECT-- +sum: 30 diff --git a/tests/thread/033-spawn_thread_exception_types.phpt b/tests/thread/033-spawn_thread_exception_types.phpt new file mode 100644 index 00000000..3cc6c30f --- /dev/null +++ b/tests/thread/033-spawn_thread_exception_types.phpt @@ -0,0 +1,51 @@ +--TEST-- +spawn_thread() - various exception types propagate correctly +--SKIPIF-- + +--FILE-- +getRemoteClass() . "\n"; + } + + // DivisionByZeroError + try { + await(spawn_thread(function() { + return intdiv(1, 0); + })); + } catch (\Async\RemoteException $e) { + echo "DivisionByZero: " . $e->getRemoteClass() . "\n"; + } + + // Custom message + try { + await(spawn_thread(function() { + throw new \InvalidArgumentException("bad input", 42); + })); + } catch (\Async\RemoteException $e) { + $remote = $e->getRemoteException(); + echo "message: " . $remote->getMessage() . "\n"; + echo "code: " . $remote->getCode() . "\n"; + } +}); +?> +--EXPECT-- +TypeError: TypeError +DivisionByZero: DivisionByZeroError +message: bad input +code: 42 diff --git a/tests/thread/034-spawn_thread_large_data.phpt b/tests/thread/034-spawn_thread_large_data.phpt new file mode 100644 index 00000000..6d40dddf --- /dev/null +++ b/tests/thread/034-spawn_thread_large_data.phpt @@ -0,0 +1,46 @@ +--TEST-- +spawn_thread() - transfer large data structures +--SKIPIF-- + +--FILE-- + $i, 'name' => "item_$i", 'values' => [$i, $i*2, $i*3]]; + } + return $data; + })); + echo "nested count: " . count($r3) . "\n"; + echo "last: " . $r3[99]['name'] . "\n"; +}); +?> +--EXPECT-- +string len: 100000 +array count: 1000 +array sum: 500500 +nested count: 100 +last: item_99 diff --git a/tests/thread/035-spawn_thread_superglobals.phpt b/tests/thread/035-spawn_thread_superglobals.phpt new file mode 100644 index 00000000..2ceadbb1 --- /dev/null +++ b/tests/thread/035-spawn_thread_superglobals.phpt @@ -0,0 +1,41 @@ +--TEST-- +spawn_thread() - $_SERVER and $_ENV available, parent globals isolated +--SKIPIF-- + +--FILE-- + isset($_SERVER), + 'has_php_self' => isset($_SERVER['PHP_SELF']), + 'has_env' => isset($_ENV), + 'getenv_works' => (getenv('PATH') !== false), + 'parent_isolated' => !isset($GLOBALS['test_var']), + ]; + })); + + echo "has_server: " . ($result['has_server'] ? 'yes' : 'no') . "\n"; + echo "has_php_self: " . ($result['has_php_self'] ? 'yes' : 'no') . "\n"; + echo "has_env: " . ($result['has_env'] ? 'yes' : 'no') . "\n"; + echo "getenv_works: " . ($result['getenv_works'] ? 'yes' : 'no') . "\n"; + echo "parent_isolated: " . ($result['parent_isolated'] ? 'yes' : 'no') . "\n"; +}); +?> +--EXPECT-- +has_server: yes +has_php_self: yes +has_env: yes +getenv_works: yes +parent_isolated: yes diff --git a/tests/thread/036-spawn_thread_return_enum.phpt b/tests/thread/036-spawn_thread_return_enum.phpt new file mode 100644 index 00000000..da35373d --- /dev/null +++ b/tests/thread/036-spawn_thread_return_enum.phpt @@ -0,0 +1,30 @@ +--TEST-- +spawn_thread() - backed enum values can be transferred +--SKIPIF-- + +--FILE-- + 42, 'str' => 'hello', 'bool' => true, 'float' => 3.14]; + })); + var_dump($r['int']); + var_dump($r['str']); + var_dump($r['bool']); + var_dump($r['float']); +}); +?> +--EXPECT-- +int(42) +string(5) "hello" +bool(true) +float(3.14) diff --git a/tests/thread/037-spawn_thread_bound_vars_identity.phpt b/tests/thread/037-spawn_thread_bound_vars_identity.phpt new file mode 100644 index 00000000..7da92e7f --- /dev/null +++ b/tests/thread/037-spawn_thread_bound_vars_identity.phpt @@ -0,0 +1,75 @@ +--TEST-- +spawn_thread() - object identity preserved across bound variables +--SKIPIF-- + +--FILE-- + $obj]; + + $t = spawn_thread(function() use ($obj, $wrapper) { + echo "A identity: ", ($obj === $wrapper['ref'] ? "yes" : "no"), "\n"; + echo "A obj->n: ", $obj->n, "\n"; + $obj->n = 999; + echo "A after mutate wrapper: ", $wrapper['ref']->n, "\n"; + return 'ok'; + }, bootloader: $boot); + $rA = await($t); + echo "A: ", $rA, "\n"; + + // Case B: same object assigned to two separate captured variables. + $h = new Holder(100); + $h2 = $h; + $t = spawn_thread(function() use ($h, $h2) { + echo "B identity: ", ($h === $h2 ? "yes" : "no"), "\n"; + return 'ok'; + }, bootloader: $boot); + $rB = await($t); + echo "B: ", $rB, "\n"; + + // Case C: two distinct objects cross-referenced from two bound vars in + // different structural positions — both must retain identity. + $a = new Holder(1); + $b = new Holder(2); + $graph1 = ['root' => $a, 'other' => $b]; + $graph2 = ['root' => $b, 'other' => $a]; + + $t = spawn_thread(function() use ($graph1, $graph2) { + echo "C a same: ", ($graph1['root'] === $graph2['other'] ? "yes" : "no"), "\n"; + echo "C b same: ", ($graph1['other'] === $graph2['root'] ? "yes" : "no"), "\n"; + return 'ok'; + }, bootloader: $boot); + $rC = await($t); + echo "C: ", $rC, "\n"; +}); +?> +--EXPECT-- +A identity: yes +A obj->n: 42 +A after mutate wrapper: 999 +A: ok +B identity: yes +B: ok +C a same: yes +C b same: yes +C: ok diff --git a/tests/thread/038-spawn_thread_transfer_weakref.phpt b/tests/thread/038-spawn_thread_transfer_weakref.phpt new file mode 100644 index 00000000..5ef65774 --- /dev/null +++ b/tests/thread/038-spawn_thread_transfer_weakref.phpt @@ -0,0 +1,78 @@ +--TEST-- +spawn_thread() - WeakReference transfers across threads +--SKIPIF-- + +--FILE-- +get() === $obj ? "yes" : "no"), "\n"; + echo "A tag: ", $wr->get()->tag, "\n"; + return 'ok'; + }, bootloader: $boot); + $rA = await($t); echo "A: ", $rA, "\n"; + + // Case B: $wr listed before $obj — identity must still survive (this is + // the load order that exposed a use-after-free before defer_release). + $obj2 = new Bag('second'); + $wr2 = WeakReference::create($obj2); + $t = spawn_thread(function() use ($wr2, $obj2) { + echo "B same: ", ($wr2->get() === $obj2 ? "yes" : "no"), "\n"; + echo "B tag: ", $wr2->get()->tag, "\n"; + return 'ok'; + }, bootloader: $boot); + $rB = await($t); echo "B: ", $rB, "\n"; + + // Case C: only the WR is captured, the referent is not — child thread + // has no strong holder, so the WR must be dead on the receiving side. + $obj3 = new Bag('lonely'); + $wr3 = WeakReference::create($obj3); + $t = spawn_thread(function() use ($wr3) { + echo "C dead: ", ($wr3->get() === null ? "yes" : "no"), "\n"; + return 'ok'; + }, bootloader: $boot); + $rC = await($t); echo "C: ", $rC, "\n"; + + // Case D: source-side referent already gone before transfer. + $obj4 = new Bag('gone'); + $wr4 = WeakReference::create($obj4); + unset($obj4); + echo "D source dead: ", ($wr4->get() === null ? "yes" : "no"), "\n"; + $t = spawn_thread(function() use ($wr4) { + echo "D dead on load: ", ($wr4->get() === null ? "yes" : "no"), "\n"; + return 'ok'; + }, bootloader: $boot); + $rD = await($t); echo "D: ", $rD, "\n"; +}); +?> +--EXPECT-- +A same: yes +A tag: first +A: ok +B same: yes +B tag: second +B: ok +C dead: yes +C: ok +D source dead: yes +D dead on load: yes +D: ok diff --git a/tests/thread/039-spawn_thread_transfer_weakmap.phpt b/tests/thread/039-spawn_thread_transfer_weakmap.phpt new file mode 100644 index 00000000..5630ac01 --- /dev/null +++ b/tests/thread/039-spawn_thread_transfer_weakmap.phpt @@ -0,0 +1,95 @@ +--TEST-- +spawn_thread() - WeakMap transfers across threads +--SKIPIF-- + +--FILE-- + true]; + + $t = spawn_thread(function() use ($wm, $k1, $k2, $k3) { + echo "A count: ", count($wm), "\n"; + echo "A k1: ", $wm[$k1], "\n"; + echo "A k2: ", $wm[$k2], "\n"; + echo "A k3.nested: ", var_export($wm[$k3]['nested'], true), "\n"; + return 'ok'; + }, bootloader: $boot); + $rA = await($t); echo "A: ", $rA, "\n"; + + // Case B: empty WeakMap. + $empty = new WeakMap(); + $t = spawn_thread(function() use ($empty) { + echo "B count: ", count($empty), "\n"; + echo "B is wm: ", ($empty instanceof WeakMap ? "yes" : "no"), "\n"; + return 'ok'; + }, bootloader: $boot); + $rB = await($t); echo "B: ", $rB, "\n"; + + // Case C: WeakMap with a key that's NOT separately captured. The + // receiving side has no strong holder, so by WeakMap semantics the + // entry must vanish (matches single-thread behaviour: a weakly held + // key with no other reference is collected immediately). + $solo = new Key('solo', 777); + $wm3 = new WeakMap(); + $wm3[$solo] = 'solo-value'; + + $t = spawn_thread(function() use ($wm3) { + echo "C count: ", count($wm3), "\n"; + return 'ok'; + }, bootloader: $boot); + $rC = await($t); echo "C: ", $rC, "\n"; + + // Case D: same setup but the key is also captured — entry survives on + // the receiving side because the closure holds the key strongly. + $kd = new Key('persisted', 99); + $wm4 = new WeakMap(); + $wm4[$kd] = 'kept'; + + $t = spawn_thread(function() use ($wm4, $kd) { + echo "D count: ", count($wm4), "\n"; + echo "D key id: ", $kd->id, "\n"; + echo "D val: ", $wm4[$kd], "\n"; + return 'ok'; + }, bootloader: $boot); + $rD = await($t); echo "D: ", $rD, "\n"; +}); +?> +--EXPECT-- +A count: 3 +A k1: value-1 +A k2: 42 +A k3.nested: true +A: ok +B count: 0 +B is wm: yes +B: ok +C count: 0 +C: ok +D count: 1 +D key id: 99 +D val: kept +D: ok diff --git a/tests/thread/040-spawn_thread_weakref_edge_cases.phpt b/tests/thread/040-spawn_thread_weakref_edge_cases.phpt new file mode 100644 index 00000000..0a800659 --- /dev/null +++ b/tests/thread/040-spawn_thread_weakref_edge_cases.phpt @@ -0,0 +1,146 @@ +--TEST-- +spawn_thread() - WeakReference / WeakMap edge cases +--SKIPIF-- + +--FILE-- +get() === $r[0] ? "yes" : "no"), "\n"; + echo "A name: ", $r[0]->name, "\n"; + + // === Case B: source-side WR singleton — two captured WRs to the same + // referent are actually the same WR instance, must remain so on the + // receiving side === + $obj = new Node('singleton'); + $wr1 = WeakReference::create($obj); + $wr2 = WeakReference::create($obj); + echo "B source same wr: ", ($wr1 === $wr2 ? "yes" : "no"), "\n"; + $t = spawn_thread(function() use ($obj, $wr1, $wr2) { + echo "B child wr1 === wr2: ", ($wr1 === $wr2 ? "yes" : "no"), "\n"; + echo "B child wr1->get() === obj: ", ($wr1->get() === $obj ? "yes" : "no"), "\n"; + return 'ok'; + }, bootloader: $boot); + $rB = await($t); echo "B result: ", $rB, "\n"; + + // === Case C: WR returned from thread when referent is NOT also returned — + // the receiving side has no strong holder, so WR must be dead === + $t = spawn_thread(function() { + $local = new Node('lonely-return'); + return WeakReference::create($local); + // $local goes out of scope here; only the WR is in retval + }, bootloader: $boot); + $wr = await($t); + echo "C is wr: ", ($wr instanceof WeakReference ? "yes" : "no"), "\n"; + echo "C dead: ", ($wr->get() === null ? "yes" : "no"), "\n"; + + // === Case D: WeakMap returned from thread with a key in the same array === + $t = spawn_thread(function() { + $k = new Node('returned-key'); + $wm = new WeakMap(); + $wm[$k] = 'returned-value'; + return [$wm, $k]; + }, bootloader: $boot); + $r = await($t); + echo "D wm count: ", count($r[0]), "\n"; + echo "D wm[k]: ", $r[0][$r[1]], "\n"; + echo "D k name: ", $r[1]->name, "\n"; + + // === Case E: WeakMap holding a WeakReference as a VALUE === + $obj = new Node('referent'); + $wr = WeakReference::create($obj); + $key = new Node('key'); + $wm = new WeakMap(); + $wm[$key] = $wr; + $t = spawn_thread(function() use ($wm, $key, $obj) { + $stored_wr = $wm[$key]; + echo "E val is wr: ", ($stored_wr instanceof WeakReference ? "yes" : "no"), "\n"; + echo "E wr resolves to obj: ", ($stored_wr->get() === $obj ? "yes" : "no"), "\n"; + return 'ok'; + }, bootloader: $boot); + $rE = await($t); echo "E result: ", $rE, "\n"; + + // === Case F: nested WeakMap (WM whose value is another WM) === + $outerKey = new Node('outer-key'); + $innerKey = new Node('inner-key'); + $inner = new WeakMap(); + $inner[$innerKey] = 'deep'; + $outer = new WeakMap(); + $outer[$outerKey] = $inner; + $t = spawn_thread(function() use ($outer, $outerKey, $innerKey) { + $innerSeen = $outer[$outerKey]; + echo "F inner is wm: ", ($innerSeen instanceof WeakMap ? "yes" : "no"), "\n"; + echo "F inner count: ", count($innerSeen), "\n"; + echo "F inner val: ", $innerSeen[$innerKey], "\n"; + return 'ok'; + }, bootloader: $boot); + $rF = await($t); echo "F result: ", $rF, "\n"; + + // === Case G: many WR transfers in one closure (sanity / leak smoke) === + $objects = []; + $wrs = []; + for ($i = 0; $i < 50; $i++) { + $o = new Node("item-$i"); + $objects[] = $o; + $wrs[] = WeakReference::create($o); + } + $t = spawn_thread(function() use ($objects, $wrs) { + $matches = 0; + for ($i = 0; $i < count($wrs); $i++) { + if ($wrs[$i]->get() === $objects[$i]) { + $matches++; + } + } + echo "G matches: ", $matches, "\n"; + return 'ok'; + }, bootloader: $boot); + $rG = await($t); echo "G result: ", $rG, "\n"; +}); +?> +--EXPECT-- +A is array: yes +A obj is Node: yes +A wr resolves: yes +A name: returned +B source same wr: yes +B child wr1 === wr2: yes +B child wr1->get() === obj: yes +B result: ok +C is wr: yes +C dead: yes +D wm count: 1 +D wm[k]: returned-value +D k name: returned-key +E val is wr: yes +E wr resolves to obj: yes +E result: ok +F inner is wm: yes +F inner count: 1 +F inner val: deep +F result: ok +G matches: 50 +G result: ok + diff --git a/tests/thread/041-thread_status_methods.phpt b/tests/thread/041-thread_status_methods.phpt new file mode 100644 index 00000000..4d8ae0c5 --- /dev/null +++ b/tests/thread/041-thread_status_methods.phpt @@ -0,0 +1,85 @@ +--TEST-- +Thread: isRunning / isCompleted / isCancelled / getResult / getException / cancel / finally +--FILE-- +getMessage() . "\n"; +} + +// 2. A thread that returns a value. +$ok = spawn_thread(function () { + return "hello"; +}); +echo "before await running=" . ($ok->isRunning() ? "T" : "F") . "\n"; +$result = await($ok); +echo "result=$result\n"; +echo "isCompleted=" . ($ok->isCompleted() ? "T" : "F") . "\n"; +echo "isRunning=" . ($ok->isRunning() ? "T" : "F") . "\n"; +echo "isCancelled=" . ($ok->isCancelled() ? "T" : "F") . "\n"; +var_dump($ok->getResult()); +var_dump($ok->getException()); + +// 3. A thread that throws. +$bad = spawn_thread(function () { + throw new \RuntimeException("kaboom"); +}); +try { + await($bad); + echo "no error\n"; +} catch (\Throwable $e) { + echo "await caught: " . $e->getMessage() . "\n"; +} +var_dump($bad->getException() !== null); + +// 4. finally() on an already-completed thread runs the callback immediately. +$ok->finally(function (\Async\Thread $t) { + echo "late finally fired, completed=" . ($t->isCompleted() ? "T" : "F") . "\n"; +}); + +// 5. cancel() on a completed thread is a no-op. +$ok->cancel(); +echo "cancel on completed = no throw\n"; + +// 6. cancel() on a running thread throws "not yet implemented". +$running = spawn_thread(function () { + return "soon"; +}); +try { + $running->cancel(); +} catch (\Throwable $e) { + echo "cancel running: " . $e->getMessage() . "\n"; +} +await($running); + +echo "end\n"; + +?> +--EXPECTF-- +start +construct: Call to private Async\Thread::__construct() from global scope +before await running=%s +result=hello +isCompleted=T +isRunning=F +isCancelled=F +string(5) "hello" +NULL +await caught: %s +bool(true) +late finally fired, completed=T +cancel on completed = no throw +cancel running: Thread cancellation is not yet implemented +end diff --git a/tests/thread/042-thread_finally_dispatch.phpt b/tests/thread/042-thread_finally_dispatch.phpt new file mode 100644 index 00000000..e13af1c4 --- /dev/null +++ b/tests/thread/042-thread_finally_dispatch.phpt @@ -0,0 +1,54 @@ +--TEST-- +Thread: finally() rejects non-callable; registration on a running thread fires at dtor +--FILE-- +finally("definitely not a function"); + } catch (\Async\AsyncException $e) { + echo "non-callable: ", $e->getMessage(), "\n"; + } + await($t); + + // 2. Register finally() on a still-running thread, then release the + // variable inside a live coroutine. The handler must fire without + // the old NULL-scope crash at dtor time. + (function() { + $t2 = spawn_thread(function() { + for ($i = 0; $i < 50000; $i++) { $x = $i * 2; } + return 'done'; + }); + $t2->finally(function($thread) { + echo "finally fired, completed=", $thread->isCompleted() ? "T" : "F", "\n"; + }); + await($t2); + })(); + + // Give the dtor-time finally spawn a chance to run. + suspend(); + suspend(); + + echo "end\n"; +}); + +?> +--EXPECT-- +non-callable: Argument #1 ($callback) must be callable +finally fired, completed=T +end diff --git a/tests/thread_channel/001-construct_basic.phpt b/tests/thread_channel/001-construct_basic.phpt new file mode 100644 index 00000000..f14be343 --- /dev/null +++ b/tests/thread_channel/001-construct_basic.phpt @@ -0,0 +1,23 @@ +--TEST-- +ThreadChannel: construct - basic instantiation +--FILE-- +capacity() . "\n"; + +$ch2 = new ThreadChannel(4); +echo "Capacity 4: " . $ch2->capacity() . "\n"; + +$ch3 = new ThreadChannel(1); +echo "Capacity 1: " . $ch3->capacity() . "\n"; + +echo "Done\n"; +?> +--EXPECT-- +Default capacity: 16 +Capacity 4: 4 +Capacity 1: 1 +Done diff --git a/tests/thread_channel/002-construct_invalid.phpt b/tests/thread_channel/002-construct_invalid.phpt new file mode 100644 index 00000000..ee7df8ff --- /dev/null +++ b/tests/thread_channel/002-construct_invalid.phpt @@ -0,0 +1,25 @@ +--TEST-- +ThreadChannel: construct - invalid capacity throws error +--FILE-- +getMessage() . "\n"; +} + +try { + new ThreadChannel(-5); +} catch (\ValueError $e) { + echo "Negative: " . $e->getMessage() . "\n"; +} + +echo "Done\n"; +?> +--EXPECT-- +Zero: Async\ThreadChannel::__construct(): Argument #1 ($capacity) must be >= 1 +Negative: Async\ThreadChannel::__construct(): Argument #1 ($capacity) must be >= 1 +Done diff --git a/tests/thread_channel/003-send_recv_sync.phpt b/tests/thread_channel/003-send_recv_sync.phpt new file mode 100644 index 00000000..0fadebb1 --- /dev/null +++ b/tests/thread_channel/003-send_recv_sync.phpt @@ -0,0 +1,42 @@ +--TEST-- +ThreadChannel: synchronous send and recv without coroutines +--FILE-- +send(42); +$ch->send("hello"); +$ch->send([1, 2, 3]); +$ch->send(true); + +echo "Count: " . $ch->count() . "\n"; +echo "Full: " . ($ch->isFull() ? "yes" : "no") . "\n"; + +var_dump($ch->recv()); +var_dump($ch->recv()); +var_dump($ch->recv()); +var_dump($ch->recv()); + +echo "Empty: " . ($ch->isEmpty() ? "yes" : "no") . "\n"; + +echo "Done\n"; +?> +--EXPECT-- +Count: 4 +Full: yes +int(42) +string(5) "hello" +array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(3) +} +bool(true) +Empty: yes +Done diff --git a/tests/thread_channel/004-state_methods.phpt b/tests/thread_channel/004-state_methods.phpt new file mode 100644 index 00000000..a96e38b1 --- /dev/null +++ b/tests/thread_channel/004-state_methods.phpt @@ -0,0 +1,42 @@ +--TEST-- +ThreadChannel: isEmpty, isFull, count state tracking +--FILE-- +isEmpty() ? "yes" : "no"); +echo " full=" . ($ch->isFull() ? "yes" : "no"); +echo " count=" . $ch->count() . "\n"; + +$ch->send("a"); +echo "After 1 send: empty=" . ($ch->isEmpty() ? "yes" : "no"); +echo " full=" . ($ch->isFull() ? "yes" : "no"); +echo " count=" . $ch->count() . "\n"; + +$ch->send("b"); +echo "After 2 sends: empty=" . ($ch->isEmpty() ? "yes" : "no"); +echo " full=" . ($ch->isFull() ? "yes" : "no"); +echo " count=" . $ch->count() . "\n"; + +$ch->recv(); +echo "After 1 recv: empty=" . ($ch->isEmpty() ? "yes" : "no"); +echo " full=" . ($ch->isFull() ? "yes" : "no"); +echo " count=" . $ch->count() . "\n"; + +$ch->recv(); +echo "After 2 recvs: empty=" . ($ch->isEmpty() ? "yes" : "no"); +echo " full=" . ($ch->isFull() ? "yes" : "no"); +echo " count=" . $ch->count() . "\n"; + +echo "Done\n"; +?> +--EXPECT-- +Initial: empty=yes full=no count=0 +After 1 send: empty=no full=no count=1 +After 2 sends: empty=no full=yes count=2 +After 1 recv: empty=no full=no count=1 +After 2 recvs: empty=yes full=no count=0 +Done diff --git a/tests/thread_channel/005-close_basic.phpt b/tests/thread_channel/005-close_basic.phpt new file mode 100644 index 00000000..94bb0b8a --- /dev/null +++ b/tests/thread_channel/005-close_basic.phpt @@ -0,0 +1,24 @@ +--TEST-- +ThreadChannel: close - basic close and isClosed +--FILE-- +isClosed() ? "closed" : "open") . "\n"; + +$ch->close(); +echo "After close: " . ($ch->isClosed() ? "closed" : "open") . "\n"; + +// Double close is a no-op +$ch->close(); +echo "After double close: " . ($ch->isClosed() ? "closed" : "open") . "\n"; + +echo "Done\n"; +?> +--EXPECT-- +Before close: open +After close: closed +After double close: closed +Done diff --git a/tests/thread_channel/006-send_on_closed.phpt b/tests/thread_channel/006-send_on_closed.phpt new file mode 100644 index 00000000..79a7eb84 --- /dev/null +++ b/tests/thread_channel/006-send_on_closed.phpt @@ -0,0 +1,22 @@ +--TEST-- +ThreadChannel: send on closed channel throws exception +--FILE-- +close(); + +try { + $ch->send(42); +} catch (ThreadChannelException $e) { + echo "Caught: " . $e->getMessage() . "\n"; +} + +echo "Done\n"; +?> +--EXPECT-- +Caught: ThreadChannel is closed +Done diff --git a/tests/thread_channel/007-recv_on_closed_empty.phpt b/tests/thread_channel/007-recv_on_closed_empty.phpt new file mode 100644 index 00000000..bb6c2dea --- /dev/null +++ b/tests/thread_channel/007-recv_on_closed_empty.phpt @@ -0,0 +1,22 @@ +--TEST-- +ThreadChannel: recv on closed empty channel throws exception +--FILE-- +close(); + +try { + $ch->recv(); +} catch (ThreadChannelException $e) { + echo "Caught: " . $e->getMessage() . "\n"; +} + +echo "Done\n"; +?> +--EXPECT-- +Caught: ThreadChannel is closed +Done diff --git a/tests/thread_channel/008-recv_closed_with_data.phpt b/tests/thread_channel/008-recv_closed_with_data.phpt new file mode 100644 index 00000000..c9c348c5 --- /dev/null +++ b/tests/thread_channel/008-recv_closed_with_data.phpt @@ -0,0 +1,31 @@ +--TEST-- +ThreadChannel: recv on closed channel drains remaining data +--FILE-- +send("a"); +$ch->send("b"); +$ch->close(); + +// Should still be able to recv buffered data +var_dump($ch->recv()); +var_dump($ch->recv()); + +// Now buffer is empty + closed — should throw +try { + $ch->recv(); +} catch (ThreadChannelException $e) { + echo "Caught: " . $e->getMessage() . "\n"; +} + +echo "Done\n"; +?> +--EXPECT-- +string(1) "a" +string(1) "b" +Caught: ThreadChannel is closed +Done diff --git a/tests/thread_channel/009-fifo_order.phpt b/tests/thread_channel/009-fifo_order.phpt new file mode 100644 index 00000000..0dc395c3 --- /dev/null +++ b/tests/thread_channel/009-fifo_order.phpt @@ -0,0 +1,24 @@ +--TEST-- +ThreadChannel: FIFO ordering preserved +--FILE-- +send($i); +} + +$results = []; +for ($i = 0; $i < 8; $i++) { + $results[] = $ch->recv(); +} + +echo implode(",", $results) . "\n"; +echo "Done\n"; +?> +--EXPECT-- +0,1,2,3,4,5,6,7 +Done diff --git a/tests/thread_channel/010-data_types.phpt b/tests/thread_channel/010-data_types.phpt new file mode 100644 index 00000000..4c1c0329 --- /dev/null +++ b/tests/thread_channel/010-data_types.phpt @@ -0,0 +1,52 @@ +--TEST-- +ThreadChannel: various data types transfer correctly +--FILE-- +send(null); +$ch->send(true); +$ch->send(false); +$ch->send(42); +$ch->send(3.14); +$ch->send("hello world"); + +// Array +$ch->send(["key" => "value", "nested" => [1, 2, 3]]); + +// Verify +var_dump($ch->recv()); // null +var_dump($ch->recv()); // true +var_dump($ch->recv()); // false +var_dump($ch->recv()); // 42 +var_dump($ch->recv()); // 3.14 +var_dump($ch->recv()); // "hello world" +var_dump($ch->recv()); // array + +echo "Done\n"; +?> +--EXPECT-- +NULL +bool(true) +bool(false) +int(42) +float(3.14) +string(11) "hello world" +array(2) { + ["key"]=> + string(5) "value" + ["nested"]=> + array(3) { + [0]=> + int(1) + [1]=> + int(2) + [2]=> + int(3) + } +} +Done diff --git a/tests/thread_channel/011-send_recv_coroutine.phpt b/tests/thread_channel/011-send_recv_coroutine.phpt new file mode 100644 index 00000000..fa9e956c --- /dev/null +++ b/tests/thread_channel/011-send_recv_coroutine.phpt @@ -0,0 +1,31 @@ +--TEST-- +ThreadChannel: send and recv from coroutines (single thread) +--FILE-- +send(1); + $ch->send(2); + $ch->send(3); + echo "All sent\n"; +}); + +spawn(function() use ($ch) { + echo "Got: " . $ch->recv() . "\n"; + echo "Got: " . $ch->recv() . "\n"; + echo "Got: " . $ch->recv() . "\n"; +}); + +echo "Done\n"; +?> +--EXPECT-- +Done +All sent +Got: 1 +Got: 2 +Got: 3 diff --git a/tests/thread_channel/012-capacity_one.phpt b/tests/thread_channel/012-capacity_one.phpt new file mode 100644 index 00000000..d1b46804 --- /dev/null +++ b/tests/thread_channel/012-capacity_one.phpt @@ -0,0 +1,25 @@ +--TEST-- +ThreadChannel: minimum capacity (1) works correctly +--FILE-- +capacity() . "\n"; + +$ch->send("only_one"); +echo "Full: " . ($ch->isFull() ? "yes" : "no") . "\n"; + +var_dump($ch->recv()); +echo "Empty: " . ($ch->isEmpty() ? "yes" : "no") . "\n"; + +echo "Done\n"; +?> +--EXPECT-- +Capacity: 1 +Full: yes +string(8) "only_one" +Empty: yes +Done diff --git a/tests/thread_channel/013-cross_thread_1to1.phpt b/tests/thread_channel/013-cross_thread_1to1.phpt new file mode 100644 index 00000000..b7d0d750 --- /dev/null +++ b/tests/thread_channel/013-cross_thread_1to1.phpt @@ -0,0 +1,39 @@ +--TEST-- +ThreadChannel: cross-thread 1 sender thread → 1 receiver (main) +--SKIPIF-- + +--FILE-- +send("from_thread_1"); + $ch->send("from_thread_2"); + $ch->send("from_thread_3"); + }); + + // Receive in main thread + echo $ch->recv() . "\n"; + echo $ch->recv() . "\n"; + echo $ch->recv() . "\n"; + + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +from_thread_1 +from_thread_2 +from_thread_3 +Done diff --git a/tests/thread_channel/014-cross_thread_1to1_reverse.phpt b/tests/thread_channel/014-cross_thread_1to1_reverse.phpt new file mode 100644 index 00000000..58743f0e --- /dev/null +++ b/tests/thread_channel/014-cross_thread_1to1_reverse.phpt @@ -0,0 +1,40 @@ +--TEST-- +ThreadChannel: cross-thread 1 sender (main) → 1 receiver thread +--SKIPIF-- + +--FILE-- +recv(); + $results[] = $ch->recv(); + $results[] = $ch->recv(); + return $results; + }); + + // Send from main thread + $ch->send(10); + $ch->send(20); + $ch->send(30); + + $results = await($thread); + echo implode(",", $results) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +10,20,30 +Done diff --git a/tests/thread_channel/015-cross_thread_2to1.phpt b/tests/thread_channel/015-cross_thread_2to1.phpt new file mode 100644 index 00000000..d59d07d9 --- /dev/null +++ b/tests/thread_channel/015-cross_thread_2to1.phpt @@ -0,0 +1,47 @@ +--TEST-- +ThreadChannel: cross-thread 2 sender threads → 1 receiver (main) +--SKIPIF-- + +--FILE-- +send("A$i"); + } + }); + + $t2 = spawn_thread(function() use ($ch) { + for ($i = 0; $i < 3; $i++) { + $ch->send("B$i"); + } + }); + + // Receive all 6 values + $results = []; + for ($i = 0; $i < 6; $i++) { + $results[] = $ch->recv(); + } + + await($t1); + await($t2); + + sort($results); + echo implode(",", $results) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +A0,A1,A2,B0,B1,B2 +Done diff --git a/tests/thread_channel/016-cross_thread_1to2.phpt b/tests/thread_channel/016-cross_thread_1to2.phpt new file mode 100644 index 00000000..9faa34b3 --- /dev/null +++ b/tests/thread_channel/016-cross_thread_1to2.phpt @@ -0,0 +1,53 @@ +--TEST-- +ThreadChannel: cross-thread 1 sender (main) → 2 receiver threads +--SKIPIF-- + +--FILE-- +recv(); + } + return $results; + }); + + $t2 = spawn_thread(function() use ($ch) { + $results = []; + for ($i = 0; $i < 3; $i++) { + $results[] = $ch->recv(); + } + return $results; + }); + + // Send 6 values from main + for ($i = 0; $i < 6; $i++) { + $ch->send($i); + } + + $r1 = await($t1); + $r2 = await($t2); + + $all = array_merge($r1, $r2); + sort($all); + echo implode(",", $all) . "\n"; + echo "Count: " . count($all) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +0,1,2,3,4,5 +Count: 6 +Done diff --git a/tests/thread_channel/017-cross_thread_2to2.phpt b/tests/thread_channel/017-cross_thread_2to2.phpt new file mode 100644 index 00000000..6bb9c303 --- /dev/null +++ b/tests/thread_channel/017-cross_thread_2to2.phpt @@ -0,0 +1,65 @@ +--TEST-- +ThreadChannel: cross-thread 2 sender threads → 2 receiver threads +--SKIPIF-- + +--FILE-- +send("S1:$i"); + } + }); + + $s2 = spawn_thread(function() use ($ch) { + for ($i = 0; $i < 5; $i++) { + $ch->send("S2:$i"); + } + }); + + // 2 receivers + $r1 = spawn_thread(function() use ($ch) { + $results = []; + for ($i = 0; $i < 5; $i++) { + $results[] = $ch->recv(); + } + return $results; + }); + + $r2 = spawn_thread(function() use ($ch) { + $results = []; + for ($i = 0; $i < 5; $i++) { + $results[] = $ch->recv(); + } + return $results; + }); + + await($s1); + await($s2); + + $res1 = await($r1); + $res2 = await($r2); + + $all = array_merge($res1, $res2); + sort($all); + echo implode(",", $all) . "\n"; + echo "Count: " . count($all) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +S1:0,S1:1,S1:2,S1:3,S1:4,S2:0,S2:1,S2:2,S2:3,S2:4 +Count: 10 +Done diff --git a/tests/thread_channel/018-cross_thread_close.phpt b/tests/thread_channel/018-cross_thread_close.phpt new file mode 100644 index 00000000..6043dc3c --- /dev/null +++ b/tests/thread_channel/018-cross_thread_close.phpt @@ -0,0 +1,42 @@ +--TEST-- +ThreadChannel: close from one thread while another is using it +--SKIPIF-- + +--FILE-- +send("before_close"); + $ch->close(); + return "thread_done"; + }); + + // Main receives data, then gets exception on closed channel + echo $ch->recv() . "\n"; + + try { + $ch->recv(); + } catch (ThreadChannelException $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + echo await($thread) . "\n"; +}); +?> +--EXPECT-- +before_close +Caught: ThreadChannel is closed +thread_done diff --git a/tests/thread_channel/019-thread_exception_after_send.phpt b/tests/thread_channel/019-thread_exception_after_send.phpt new file mode 100644 index 00000000..f7df4be5 --- /dev/null +++ b/tests/thread_channel/019-thread_exception_after_send.phpt @@ -0,0 +1,44 @@ +--TEST-- +ThreadChannel: thread throws exception after partial send — main recv gets available data +--SKIPIF-- + +--FILE-- +send("msg1"); + $ch->send("msg2"); + throw new \RuntimeException("thread crashed"); + }); + + // Receive what was sent before the crash + echo $ch->recv() . "\n"; + echo $ch->recv() . "\n"; + + // Thread threw — await should propagate the exception + try { + await($thread); + } catch (RemoteException $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + echo "Done\n"; +}); +?> +--EXPECT-- +msg1 +msg2 +Caught: thread crashed +Done diff --git a/tests/thread_channel/020-thread_exception_before_send.phpt b/tests/thread_channel/020-thread_exception_before_send.phpt new file mode 100644 index 00000000..3c63021d --- /dev/null +++ b/tests/thread_channel/020-thread_exception_before_send.phpt @@ -0,0 +1,47 @@ +--TEST-- +ThreadChannel: thread throws exception before any send — main must not hang +--SKIPIF-- + +--FILE-- +getMessage() . "\n"; + $ch->close(); + } + + // recv on closed channel should throw + try { + $ch->recv(); + } catch (ThreadChannelException $e) { + echo "Recv: " . $e->getMessage() . "\n"; + } + + echo "Done\n"; +}); +?> +--EXPECT-- +Thread failed: instant crash +Recv: ThreadChannel is closed +Done diff --git a/tests/thread_channel/021-backpressure_send_blocks.phpt b/tests/thread_channel/021-backpressure_send_blocks.phpt new file mode 100644 index 00000000..fe75ff0e --- /dev/null +++ b/tests/thread_channel/021-backpressure_send_blocks.phpt @@ -0,0 +1,43 @@ +--TEST-- +ThreadChannel: send blocks when buffer full, recv from thread unblocks it +--SKIPIF-- + +--FILE-- +recv(); + } + return $results; + }); + + // First two fill the buffer, third and fourth will block until thread recvs + $ch->send("a"); + $ch->send("b"); + $ch->send("c"); + $ch->send("d"); + echo "All sent\n"; + + $results = await($thread); + echo implode(",", $results) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +All sent +a,b,c,d +Done diff --git a/tests/thread_channel/022-send_blocked_channel_closed.phpt b/tests/thread_channel/022-send_blocked_channel_closed.phpt new file mode 100644 index 00000000..4b6358ef --- /dev/null +++ b/tests/thread_channel/022-send_blocked_channel_closed.phpt @@ -0,0 +1,45 @@ +--TEST-- +ThreadChannel: send blocks on full buffer, channel closed — sender gets exception +--SKIPIF-- + +--FILE-- +recv(); // take one item to let main proceed + $ch->close(); + return "closed"; + }); + + $ch->send("first"); // fills buffer + + // This send blocks (buffer full), then channel is closed + try { + $ch->send("second"); + echo "ERROR: send should have thrown\n"; + } catch (ThreadChannelException $e) { + echo "Send blocked then closed: " . $e->getMessage() . "\n"; + } + + echo await($thread) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +Send blocked then closed: ThreadChannel is closed +closed +Done diff --git a/tests/thread_channel/023-recv_blocked_channel_closed.phpt b/tests/thread_channel/023-recv_blocked_channel_closed.phpt new file mode 100644 index 00000000..592f0db8 --- /dev/null +++ b/tests/thread_channel/023-recv_blocked_channel_closed.phpt @@ -0,0 +1,41 @@ +--TEST-- +ThreadChannel: recv blocks on empty buffer, channel closed from thread — receiver gets exception +--SKIPIF-- + +--FILE-- +close(); + return "closed"; + }); + + // recv blocks (empty buffer), then channel is closed + try { + $ch->recv(); + echo "ERROR: recv should have thrown\n"; + } catch (ThreadChannelException $e) { + echo "Recv blocked then closed: " . $e->getMessage() . "\n"; + } + + echo await($thread) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +Recv blocked then closed: ThreadChannel is closed +closed +Done diff --git a/tests/thread_channel/024-multiple_coroutines_recv.phpt b/tests/thread_channel/024-multiple_coroutines_recv.phpt new file mode 100644 index 00000000..cb03112a --- /dev/null +++ b/tests/thread_channel/024-multiple_coroutines_recv.phpt @@ -0,0 +1,45 @@ +--TEST-- +ThreadChannel: multiple coroutines in main thread wait on recv from same channel +--SKIPIF-- + +--FILE-- +recv(); + }); + + $c2 = spawn(function() use ($ch) { + return "c2:" . $ch->recv(); + }); + + // Thread sends two values — each coroutine gets one + $thread = spawn_thread(function() use ($ch) { + $ch->send("hello"); + $ch->send("world"); + }); + + $results = [await($c1), await($c2)]; + sort($results); + echo implode("\n", $results) . "\n"; + + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +c1:hello +c2:world +Done diff --git a/tests/thread_channel/025-channel_outlives_scope.phpt b/tests/thread_channel/025-channel_outlives_scope.phpt new file mode 100644 index 00000000..2d3191d7 --- /dev/null +++ b/tests/thread_channel/025-channel_outlives_scope.phpt @@ -0,0 +1,39 @@ +--TEST-- +ThreadChannel: channel survives after variable goes out of scope — refcount protects +--SKIPIF-- + +--FILE-- +send("from_thread"); + return $ch->recv(); + }); + + $ch->send("from_main"); + echo $ch->recv() . "\n"; + + // $ch goes out of scope here, but thread still has a reference + return $thread; + })(); + + echo await($thread) . "\n"; + echo "Done\n"; +}); +?> +--EXPECTF-- +from_%s +from_%s +Done diff --git a/tests/thread_channel/026-send_non_transferable.phpt b/tests/thread_channel/026-send_non_transferable.phpt new file mode 100644 index 00000000..c1e190e3 --- /dev/null +++ b/tests/thread_channel/026-send_non_transferable.phpt @@ -0,0 +1,25 @@ +--TEST-- +ThreadChannel: send non-transferable value (resource) throws exception +--SKIPIF-- + +--FILE-- +send(fopen("/dev/null", "r")); + echo "ERROR: should have thrown\n"; +} catch (\Error $e) { + echo "Caught: " . $e->getMessage() . "\n"; +} + +echo "Done\n"; +?> +--EXPECT-- +Caught: Cannot transfer a resource between threads +Done diff --git a/tests/thread_channel/027-cross_thread_fifo.phpt b/tests/thread_channel/027-cross_thread_fifo.phpt new file mode 100644 index 00000000..acba7df6 --- /dev/null +++ b/tests/thread_channel/027-cross_thread_fifo.phpt @@ -0,0 +1,39 @@ +--TEST-- +ThreadChannel: FIFO order preserved in cross-thread communication +--SKIPIF-- + +--FILE-- +send($i); + } + }); + + $results = []; + for ($i = 0; $i < 10; $i++) { + $results[] = $ch->recv(); + } + + await($thread); + + // Must be in exact order, not just sorted + echo implode(",", $results) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +0,1,2,3,4,5,6,7,8,9 +Done diff --git a/tests/thread_channel/028-thread_no_await_gc.phpt b/tests/thread_channel/028-thread_no_await_gc.phpt new file mode 100644 index 00000000..c4c9adc9 --- /dev/null +++ b/tests/thread_channel/028-thread_no_await_gc.phpt @@ -0,0 +1,38 @@ +--TEST-- +ThreadChannel: thread object goes out of scope — channel still works, no leak +--SKIPIF-- + +--FILE-- +send("data1"); + $ch->send("data2"); + }); + await($thread); + // $thread goes out of scope here + })(); + + // Channel should still have data + echo $ch->recv() . "\n"; + echo $ch->recv() . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +data1 +data2 +Done diff --git a/tests/thread_channel/029-double_close.phpt b/tests/thread_channel/029-double_close.phpt new file mode 100644 index 00000000..69e3733c --- /dev/null +++ b/tests/thread_channel/029-double_close.phpt @@ -0,0 +1,28 @@ +--TEST-- +ThreadChannel: double close does not crash +--SKIPIF-- + +--FILE-- +send("data"); +$ch->close(); + +echo "Closed once\n"; + +$ch->close(); + +echo "Closed twice\n"; +echo "isClosed: " . ($ch->isClosed() ? "yes" : "no") . "\n"; +echo "Done\n"; +?> +--EXPECT-- +Closed once +Closed twice +isClosed: yes +Done diff --git a/tests/thread_channel/030-ping_pong.phpt b/tests/thread_channel/030-ping_pong.phpt new file mode 100644 index 00000000..8e3fbd87 --- /dev/null +++ b/tests/thread_channel/030-ping_pong.phpt @@ -0,0 +1,42 @@ +--TEST-- +ThreadChannel: ping-pong between main and thread using two channels +--SKIPIF-- + +--FILE-- +recv(); + $to_main->send("pong:$msg"); + } + }); + + for ($i = 0; $i < 5; $i++) { + $to_thread->send("ping$i"); + echo $to_main->recv() . "\n"; + } + + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +pong:ping0 +pong:ping1 +pong:ping2 +pong:ping3 +pong:ping4 +Done diff --git a/tests/thread_channel/031-many_messages.phpt b/tests/thread_channel/031-many_messages.phpt new file mode 100644 index 00000000..ae2a027a --- /dev/null +++ b/tests/thread_channel/031-many_messages.phpt @@ -0,0 +1,44 @@ +--TEST-- +ThreadChannel: 1000 messages — stress test for race conditions +--SKIPIF-- + +--FILE-- +send($i); + } + }); + + $sum = 0; + for ($i = 0; $i < $count; $i++) { + $sum += $ch->recv(); + } + + await($thread); + + $expected = ($count - 1) * $count / 2; + echo "Sum: $sum\n"; + echo "Expected: $expected\n"; + echo "Match: " . ($sum === $expected ? "yes" : "no") . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +Sum: 499500 +Expected: 499500 +Match: yes +Done diff --git a/tests/thread_channel/032-large_data.phpt b/tests/thread_channel/032-large_data.phpt new file mode 100644 index 00000000..3251e0e7 --- /dev/null +++ b/tests/thread_channel/032-large_data.phpt @@ -0,0 +1,48 @@ +--TEST-- +ThreadChannel: large string and array transfer +--SKIPIF-- + +--FILE-- +send(str_repeat("X", 1024 * 1024)); + + // Large array + $arr = []; + for ($i = 0; $i < 1000; $i++) { + $arr["key_$i"] = "value_$i"; + } + $ch->send($arr); + }); + + $str = $ch->recv(); + echo "String length: " . strlen($str) . "\n"; + echo "String content OK: " . ($str === str_repeat("X", 1024 * 1024) ? "yes" : "no") . "\n"; + + $arr = $ch->recv(); + echo "Array count: " . count($arr) . "\n"; + echo "Array sample: " . $arr["key_500"] . "\n"; + + await($thread); + echo "Done\n"; +}); +?> +--EXPECT-- +String length: 1048576 +String content OK: yes +Array count: 1000 +Array sample: value_500 +Done diff --git a/tests/thread_channel/033-sender_closes_after_send.phpt b/tests/thread_channel/033-sender_closes_after_send.phpt new file mode 100644 index 00000000..708f4ab2 --- /dev/null +++ b/tests/thread_channel/033-sender_closes_after_send.phpt @@ -0,0 +1,50 @@ +--TEST-- +ThreadChannel: thread sends data then closes — main drains remaining +--SKIPIF-- + +--FILE-- +send("a"); + $ch->send("b"); + $ch->send("c"); + $ch->close(); + }); + + await($thread); + + // Channel is closed but has data — drain it + $results = []; + while (!$ch->isEmpty()) { + $results[] = $ch->recv(); + } + + echo implode(",", $results) . "\n"; + + // Next recv should throw + try { + $ch->recv(); + } catch (ThreadChannelException $e) { + echo "After drain: " . $e->getMessage() . "\n"; + } + + echo "Done\n"; +}); +?> +--EXPECT-- +a,b,c +After drain: ThreadChannel is closed +Done diff --git a/tests/thread_channel/034-capacity_boundary.phpt b/tests/thread_channel/034-capacity_boundary.phpt new file mode 100644 index 00000000..820b008d --- /dev/null +++ b/tests/thread_channel/034-capacity_boundary.phpt @@ -0,0 +1,50 @@ +--TEST-- +ThreadChannel: exactly capacity messages — buffer full but no blocking +--SKIPIF-- + +--FILE-- +send($i); + } + return "sent_all"; + }); + + $result = await($thread); + echo "Thread: $result\n"; + + echo "isFull: " . ($ch->isFull() ? "yes" : "no") . "\n"; + echo "count: " . $ch->count() . "\n"; + + $results = []; + for ($i = 0; $i < $capacity; $i++) { + $results[] = $ch->recv(); + } + echo implode(" ", $results) . "\n"; + + echo "isEmpty: " . ($ch->isEmpty() ? "yes" : "no") . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +Thread: sent_all +isFull: yes +count: 5 +0 1 2 3 4 +isEmpty: yes +Done diff --git a/tests/thread_channel/035-thread_recv_blocked_main_closes.phpt b/tests/thread_channel/035-thread_recv_blocked_main_closes.phpt new file mode 100644 index 00000000..f5275222 --- /dev/null +++ b/tests/thread_channel/035-thread_recv_blocked_main_closes.phpt @@ -0,0 +1,39 @@ +--TEST-- +ThreadChannel: thread blocks on recv, main closes channel — thread gets exception +--SKIPIF-- + +--FILE-- +recv(); + return "ERROR: recv should have thrown"; + } catch (\Throwable $e) { + return "Caught in thread: " . $e->getMessage(); + } + }); + + // Close channel while thread is waiting on recv + $ch->close(); + + echo await($thread) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +Caught in thread: ThreadChannel is closed +Done diff --git a/tests/thread_channel/036-multiple_channels.phpt b/tests/thread_channel/036-multiple_channels.phpt new file mode 100644 index 00000000..1e4d407a --- /dev/null +++ b/tests/thread_channel/036-multiple_channels.phpt @@ -0,0 +1,43 @@ +--TEST-- +ThreadChannel: two channels in opposite directions between same threads +--SKIPIF-- + +--FILE-- +recv(); + $responses->send("reply:$req"); + } + }); + + $requests->send("hello"); + $requests->send("world"); + $requests->send("end"); + + echo $responses->recv() . "\n"; + echo $responses->recv() . "\n"; + echo $responses->recv() . "\n"; + + await($worker); + echo "Done\n"; +}); +?> +--EXPECT-- +reply:hello +reply:world +reply:end +Done diff --git a/tests/thread_channel/037-closure_transfer.phpt b/tests/thread_channel/037-closure_transfer.phpt new file mode 100644 index 00000000..9ddeb8dd --- /dev/null +++ b/tests/thread_channel/037-closure_transfer.phpt @@ -0,0 +1,48 @@ +--TEST-- +ThreadChannel: closure transfer between threads +--SKIPIF-- + +--FILE-- +send(fn() => 42); + + // Closure with args + $ch->send(fn(int $a, int $b) => $a + $b); + + // Closure with captured variable + $multiplier = 10; + $ch->send(fn(int $x) => $x * $multiplier); + + $t = spawn_thread(function() use ($ch) { + $fn1 = $ch->recv(); + echo $fn1() . "\n"; + + $fn2 = $ch->recv(); + echo $fn2(10, 20) . "\n"; + + $fn3 = $ch->recv(); + echo $fn3(7) . "\n"; + }); + + await($t); + echo "Done\n"; +}); +?> +--EXPECT-- +42 +30 +70 +Done diff --git a/tests/thread_pool/001-basic_submit.phpt b/tests/thread_pool/001-basic_submit.phpt new file mode 100644 index 00000000..7f949119 --- /dev/null +++ b/tests/thread_pool/001-basic_submit.phpt @@ -0,0 +1,27 @@ +--TEST-- +ThreadPool: basic submit and await result +--SKIPIF-- + +--FILE-- +submit(fn() => 42); + echo await($future) . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +42 +Done diff --git a/tests/thread_pool/002-submit_with_args.phpt b/tests/thread_pool/002-submit_with_args.phpt new file mode 100644 index 00000000..331380f6 --- /dev/null +++ b/tests/thread_pool/002-submit_with_args.phpt @@ -0,0 +1,27 @@ +--TEST-- +ThreadPool: submit with arguments +--SKIPIF-- + +--FILE-- +submit(fn(int $a, int $b) => $a + $b, 10, 20); + echo await($future) . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +30 +Done diff --git a/tests/thread_pool/003-submit_multiple.phpt b/tests/thread_pool/003-submit_multiple.phpt new file mode 100644 index 00000000..df456021 --- /dev/null +++ b/tests/thread_pool/003-submit_multiple.phpt @@ -0,0 +1,36 @@ +--TEST-- +ThreadPool: submit multiple tasks, collect results +--SKIPIF-- + +--FILE-- +submit(fn(int $x) => $x * $x, $i); + } + + $results = []; + foreach ($futures as $f) { + $results[] = await($f); + } + + echo implode(",", $results) . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +0,1,4,9,16 +Done diff --git a/tests/thread_pool/004-submit_exception.phpt b/tests/thread_pool/004-submit_exception.phpt new file mode 100644 index 00000000..9a88f432 --- /dev/null +++ b/tests/thread_pool/004-submit_exception.phpt @@ -0,0 +1,35 @@ +--TEST-- +ThreadPool: task throws exception — future rejects +--SKIPIF-- + +--FILE-- +submit(function() { + throw new \RuntimeException("task failed"); + }); + + try { + await($future); + echo "ERROR: should have thrown\n"; + } catch (\Throwable $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +Caught: task failed +Done diff --git a/tests/thread_pool/005-map_basic.phpt b/tests/thread_pool/005-map_basic.phpt new file mode 100644 index 00000000..d5235b5d --- /dev/null +++ b/tests/thread_pool/005-map_basic.phpt @@ -0,0 +1,28 @@ +--TEST-- +ThreadPool: map applies function to all items in parallel +--SKIPIF-- + +--FILE-- +map([1, 2, 3, 4, 5], fn(int $x) => $x * 2); + + echo implode(",", $results) . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +2,4,6,8,10 +Done diff --git a/tests/thread_pool/006-map_preserves_keys.phpt b/tests/thread_pool/006-map_preserves_keys.phpt new file mode 100644 index 00000000..0c588eb0 --- /dev/null +++ b/tests/thread_pool/006-map_preserves_keys.phpt @@ -0,0 +1,34 @@ +--TEST-- +ThreadPool: map preserves array keys and order +--SKIPIF-- + +--FILE-- +map( + ['a' => 'hello', 'b' => 'world'], + fn(string $s) => strtoupper($s) + ); + + foreach ($results as $k => $v) { + echo "$k: $v\n"; + } + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +a: HELLO +b: WORLD +Done diff --git a/tests/thread_pool/007-close_rejects_new.phpt b/tests/thread_pool/007-close_rejects_new.phpt new file mode 100644 index 00000000..b12bc4fc --- /dev/null +++ b/tests/thread_pool/007-close_rejects_new.phpt @@ -0,0 +1,34 @@ +--TEST-- +ThreadPool: close rejects new submissions +--SKIPIF-- + +--FILE-- +close(); + + try { + $pool->submit(fn() => 42); + echo "ERROR: should have thrown\n"; + } catch (ThreadPoolException $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + echo "isClosed: " . ($pool->isClosed() ? "yes" : "no") . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +Caught: ThreadPool is closed +isClosed: yes +Done diff --git a/tests/thread_pool/008-close_running_finish.phpt b/tests/thread_pool/008-close_running_finish.phpt new file mode 100644 index 00000000..5c07a1c3 --- /dev/null +++ b/tests/thread_pool/008-close_running_finish.phpt @@ -0,0 +1,32 @@ +--TEST-- +ThreadPool: close lets running tasks finish +--SKIPIF-- + +--FILE-- +submit(fn() => "task1"); + $f2 = $pool->submit(fn() => "task2"); + + $pool->close(); + + // Already submitted tasks should complete + echo await($f1) . "\n"; + echo await($f2) . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +task1 +task2 +Done diff --git a/tests/thread_pool/009-cancel.phpt b/tests/thread_pool/009-cancel.phpt new file mode 100644 index 00000000..926bd121 --- /dev/null +++ b/tests/thread_pool/009-cancel.phpt @@ -0,0 +1,56 @@ +--TEST-- +ThreadPool: cancel rejects pending backlog, lets running tasks finish +--SKIPIF-- + +--FILE-- +submit(fn() => (function() { + $start = microtime(true); + while (microtime(true) - $start < 0.2) {} + return "f1 done"; + })()); + + // Give the worker a chance to actually pick up f1. + while ($pool->getRunningCount() === 0) { + delay(5); + } + + // f2 goes into the backlog — worker is busy with f1. + $f2 = $pool->submit(fn() => "f2 done"); + + // cancel() must reject f2, f1 keeps running. + $pool->cancel(); + + try { + echo await($f1) . "\n"; + } catch (\Throwable $e) { + echo "f1 unexpectedly rejected: " . $e->getMessage() . "\n"; + } + + try { + $r = await($f2); + echo "f2 unexpectedly completed: $r\n"; + } catch (\Throwable $e) { + echo "f2 cancelled\n"; + } + + echo "Done\n"; +}); +?> +--EXPECT-- +f1 done +f2 cancelled +Done diff --git a/tests/thread_pool/010-worker_count.phpt b/tests/thread_pool/010-worker_count.phpt new file mode 100644 index 00000000..6134e2aa --- /dev/null +++ b/tests/thread_pool/010-worker_count.phpt @@ -0,0 +1,23 @@ +--TEST-- +ThreadPool: getWorkerCount returns correct number +--SKIPIF-- + +--FILE-- +getWorkerCount() . "\n"; + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +Workers: 4 +Done diff --git a/tests/thread_pool/011-counts.phpt b/tests/thread_pool/011-counts.phpt new file mode 100644 index 00000000..d6558fb8 --- /dev/null +++ b/tests/thread_pool/011-counts.phpt @@ -0,0 +1,38 @@ +--TEST-- +ThreadPool: getPendingCount, getRunningCount, count +--SKIPIF-- + +--FILE-- +count() . "\n"; + + $f1 = $pool->submit(fn() => "a"); + $f2 = $pool->submit(fn() => "b"); + + echo "After submit: count=" . $pool->count() . "\n"; + + await($f1); + await($f2); + + echo "After await: count=" . $pool->count() . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECTF-- +Before: count=0 +After submit: count=%d +After await: count=0 +Done diff --git a/tests/thread_pool/012-invalid_workers.phpt b/tests/thread_pool/012-invalid_workers.phpt new file mode 100644 index 00000000..8a2363d7 --- /dev/null +++ b/tests/thread_pool/012-invalid_workers.phpt @@ -0,0 +1,30 @@ +--TEST-- +ThreadPool: invalid worker count throws +--SKIPIF-- + +--FILE-- +getMessage() . "\n"; +} + +try { + new ThreadPool(-1); +} catch (\ValueError $e) { + echo "Negative: " . $e->getMessage() . "\n"; +} + +echo "Done\n"; +?> +--EXPECTF-- +Zero: %s +Negative: %s +Done diff --git a/tests/thread_pool/013-complex_data.phpt b/tests/thread_pool/013-complex_data.phpt new file mode 100644 index 00000000..308164da --- /dev/null +++ b/tests/thread_pool/013-complex_data.phpt @@ -0,0 +1,36 @@ +--TEST-- +ThreadPool: complex data types in submit/result +--SKIPIF-- + +--FILE-- +submit(function(array $data) { + return [ + 'sum' => array_sum($data['values']), + 'name' => strtoupper($data['name']), + ]; + }, ['name' => 'test', 'values' => [1, 2, 3, 4, 5]]); + + $result = await($future); + echo "sum: " . $result['sum'] . "\n"; + echo "name: " . $result['name'] . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +sum: 15 +name: TEST +Done diff --git a/tests/thread_pool/014-many_tasks.phpt b/tests/thread_pool/014-many_tasks.phpt new file mode 100644 index 00000000..90f735fc --- /dev/null +++ b/tests/thread_pool/014-many_tasks.phpt @@ -0,0 +1,46 @@ +--TEST-- +ThreadPool: many tasks stress test +--SKIPIF-- + +--FILE-- +submit(fn(int $x) => $x * $x, $i); + } + + $sum = 0; + foreach ($futures as $f) { + $sum += await($f); + } + + $expected = 0; + for ($i = 0; $i < $count; $i++) { + $expected += $i * $i; + } + + echo "Sum: $sum\n"; + echo "Expected: $expected\n"; + echo "Match: " . ($sum === $expected ? "yes" : "no") . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +Sum: 328350 +Expected: 328350 +Match: yes +Done diff --git a/tests/thread_pool/015-map_exception.phpt b/tests/thread_pool/015-map_exception.phpt new file mode 100644 index 00000000..8f8a7131 --- /dev/null +++ b/tests/thread_pool/015-map_exception.phpt @@ -0,0 +1,36 @@ +--TEST-- +ThreadPool: map with exception in one item +--SKIPIF-- + +--FILE-- +map([1, 2, 0, 4], function(int $x) { + if ($x === 0) { + throw new \RuntimeException("division by zero"); + } + return 10 / $x; + }); + echo "ERROR: should have thrown\n"; + } catch (\Throwable $e) { + echo "Caught: " . $e->getMessage() . "\n"; + } + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +Caught: division by zero +Done diff --git a/tests/thread_pool/016-double_close.phpt b/tests/thread_pool/016-double_close.phpt new file mode 100644 index 00000000..894f48eb --- /dev/null +++ b/tests/thread_pool/016-double_close.phpt @@ -0,0 +1,24 @@ +--TEST-- +ThreadPool: double close does not crash +--SKIPIF-- + +--FILE-- +close(); + $pool->close(); + echo "isClosed: " . ($pool->isClosed() ? "yes" : "no") . "\n"; + echo "Done\n"; +}); +?> +--EXPECT-- +isClosed: yes +Done diff --git a/tests/thread_pool/017-submit_return_types.phpt b/tests/thread_pool/017-submit_return_types.phpt new file mode 100644 index 00000000..bd529224 --- /dev/null +++ b/tests/thread_pool/017-submit_return_types.phpt @@ -0,0 +1,36 @@ +--TEST-- +ThreadPool: submit returns various types correctly +--SKIPIF-- + +--FILE-- +submit(fn() => 42)) . "\n"; + echo "float: " . await($pool->submit(fn() => 3.14)) . "\n"; + echo "string: " . await($pool->submit(fn() => "hello")) . "\n"; + echo "bool: " . (await($pool->submit(fn() => true)) ? "true" : "false") . "\n"; + echo "null: " . var_export(await($pool->submit(fn() => null)), true) . "\n"; + echo "array: " . implode(",", await($pool->submit(fn() => [1, 2, 3]))) . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +int: 42 +float: 3.14 +string: hello +bool: true +null: NULL +array: 1,2,3 +Done diff --git a/tests/thread_pool/018-map_empty.phpt b/tests/thread_pool/018-map_empty.phpt new file mode 100644 index 00000000..35b57e06 --- /dev/null +++ b/tests/thread_pool/018-map_empty.phpt @@ -0,0 +1,28 @@ +--TEST-- +ThreadPool: map with empty array +--SKIPIF-- + +--FILE-- +map([], fn($x) => $x * 2); + echo "Count: " . count($results) . "\n"; + echo "Type: " . gettype($results) . "\n"; + + $pool->close(); + echo "Done\n"; +}); +?> +--EXPECT-- +Count: 0 +Type: array +Done diff --git a/tests/thread_pool/019-submit_after_cancel.phpt b/tests/thread_pool/019-submit_after_cancel.phpt new file mode 100644 index 00000000..16685fba --- /dev/null +++ b/tests/thread_pool/019-submit_after_cancel.phpt @@ -0,0 +1,28 @@ +--TEST-- +ThreadPool: submit after cancel throws ThreadPoolException +--SKIPIF-- + +--FILE-- +cancel(); + + try { + $pool->submit(fn() => 42); + echo "no exception\n"; + } catch (ThreadPoolException $e) { + echo "rejected: " . $e->getMessage() . "\n"; + } +}); +?> +--EXPECT-- +rejected: ThreadPool is closed diff --git a/tests/thread_pool/020-cancel_on_idle.phpt b/tests/thread_pool/020-cancel_on_idle.phpt new file mode 100644 index 00000000..49ad0548 --- /dev/null +++ b/tests/thread_pool/020-cancel_on_idle.phpt @@ -0,0 +1,30 @@ +--TEST-- +ThreadPool: cancel on idle pool (all tasks already drained) +--SKIPIF-- + +--FILE-- +submit(fn() => "warm")) . "\n"; + + // Now cancel an idle pool — no exceptions, no hangs. + $pool->cancel(); + echo "cancelled\n"; + var_dump($pool->isClosed()); +}); +?> +--EXPECT-- +warm +cancelled +bool(true) diff --git a/tests/thread_pool/021-cancel_on_empty_pool.phpt b/tests/thread_pool/021-cancel_on_empty_pool.phpt new file mode 100644 index 00000000..7fb1d614 --- /dev/null +++ b/tests/thread_pool/021-cancel_on_empty_pool.phpt @@ -0,0 +1,25 @@ +--TEST-- +ThreadPool: cancel immediately after construction, no submits +--SKIPIF-- + +--FILE-- +cancel(); + echo "closed=" . ($pool->isClosed() ? "yes" : "no") . "\n"; + echo "workers=" . $pool->getWorkerCount() . "\n"; + echo "done\n"; +}); +?> +--EXPECT-- +closed=yes +workers=4 +done diff --git a/tests/thread_pool/022-unused_pool_destroy.phpt b/tests/thread_pool/022-unused_pool_destroy.phpt new file mode 100644 index 00000000..357eb768 --- /dev/null +++ b/tests/thread_pool/022-unused_pool_destroy.phpt @@ -0,0 +1,24 @@ +--TEST-- +ThreadPool: pool goes out of scope without explicit close (quiesce regression) +--SKIPIF-- + +--FILE-- + +--EXPECT-- +after spawn +created diff --git a/tests/thread_pool/023-queue_backpressure.phpt b/tests/thread_pool/023-queue_backpressure.phpt new file mode 100644 index 00000000..3caa63d0 --- /dev/null +++ b/tests/thread_pool/023-queue_backpressure.phpt @@ -0,0 +1,53 @@ +--TEST-- +ThreadPool: submit() suspends the coroutine when the queue is full +--SKIPIF-- + +--FILE-- +submit(fn() => (function() { + $s = microtime(true); + while (microtime(true) - $s < 0.15) {} + return "t1"; + })()); + + // Wait until the worker actually picked up t1 — freeing the 1-slot queue. + while ($pool->getRunningCount() === 0) { + delay(5); + } + + // t2 fits into the now-empty buffer without blocking. + $f2 = $pool->submit(fn() => "t2"); + + // t3: buffer is full (t2 sits there), submit must suspend until + // the worker picks up t2 — which only happens after t1 finishes. + $start = microtime(true); + $f3 = $pool->submit(fn() => "t3"); + $elapsed = microtime(true) - $start; + + echo ($elapsed > 0.03 ? "blocked" : "did not block") . "\n"; + + $r1 = await($f1); + $r2 = await($f2); + $r3 = await($f3); + echo "$r1,$r2,$r3\n"; + + $pool->close(); +}); +?> +--EXPECT-- +blocked +t1,t2,t3 diff --git a/tests/thread_pool/024-closure_with_bound_vars.phpt b/tests/thread_pool/024-closure_with_bound_vars.phpt new file mode 100644 index 00000000..d4a2c0de --- /dev/null +++ b/tests/thread_pool/024-closure_with_bound_vars.phpt @@ -0,0 +1,31 @@ +--TEST-- +ThreadPool: closures transfer bound variables (use clause) via snapshot +--SKIPIF-- + +--FILE-- +submit(function() use ($prefix, $suffix, $count) { + return str_repeat("$prefix $suffix ", $count); + }); + + echo await($f) . "\n"; + $pool->close(); +}); +?> +--EXPECT-- +hello world hello world hello world diff --git a/tests/thread_pool/025-concurrent_submits.phpt b/tests/thread_pool/025-concurrent_submits.phpt new file mode 100644 index 00000000..ae5eed0e --- /dev/null +++ b/tests/thread_pool/025-concurrent_submits.phpt @@ -0,0 +1,38 @@ +--TEST-- +ThreadPool: concurrent submits from multiple coroutines +--SKIPIF-- + +--FILE-- +submit(fn() => $n * 10)); + }); + } + + $results = []; + foreach ($coros as $c) { + $results[] = await($c); + } + + sort($results); + echo implode(',', $results) . "\n"; + + $pool->close(); +}); +?> +--EXPECT-- +0,10,20,30,40,50,60,70 diff --git a/tests/thread_pool/026-count_countable.phpt b/tests/thread_pool/026-count_countable.phpt new file mode 100644 index 00000000..86482a97 --- /dev/null +++ b/tests/thread_pool/026-count_countable.phpt @@ -0,0 +1,31 @@ +--TEST-- +ThreadPool: count() via Countable interface returns pending+running total +--SKIPIF-- + +--FILE-- +submit(fn() => 42); + await($f); + echo count($pool) . "\n"; + + $pool->close(); +}); +?> +--EXPECT-- +bool(true) +0 +0 diff --git a/tests/thread_pool/027-await_rejected_future_message.phpt b/tests/thread_pool/027-await_rejected_future_message.phpt new file mode 100644 index 00000000..a75e99cc --- /dev/null +++ b/tests/thread_pool/027-await_rejected_future_message.phpt @@ -0,0 +1,33 @@ +--TEST-- +ThreadPool: exception class and message preserved across thread boundary +--SKIPIF-- + +--FILE-- +submit(fn() => throw new RuntimeException("boom from worker")); + + try { + await($f); + echo "unexpected success\n"; + } catch (\Throwable $e) { + echo get_class($e) . "\n"; + echo $e->getMessage() . "\n"; + } + + $pool->close(); +}); +?> +--EXPECTF-- +%SRuntimeException%S +%Sboom from worker%S diff --git a/tests/thread_pool/028-submit_during_close_race.phpt b/tests/thread_pool/028-submit_during_close_race.phpt new file mode 100644 index 00000000..5c9a1c88 --- /dev/null +++ b/tests/thread_pool/028-submit_during_close_race.phpt @@ -0,0 +1,30 @@ +--TEST-- +ThreadPool: close() right after submit() still resolves the in-flight future +--SKIPIF-- + +--FILE-- +submit(fn() => "first"); + // Graceful close — worker must still drain the already-queued task. + $pool->close(); + + try { + echo await($f) . "\n"; + } catch (\Throwable $e) { + echo "unexpected rejection: " . $e->getMessage() . "\n"; + } +}); +?> +--EXPECT-- +first diff --git a/tests/thread_pool/029-map_mixed_results.phpt b/tests/thread_pool/029-map_mixed_results.phpt new file mode 100644 index 00000000..4d2a3cb1 --- /dev/null +++ b/tests/thread_pool/029-map_mixed_results.phpt @@ -0,0 +1,32 @@ +--TEST-- +ThreadPool: map() with heterogeneous return types per item +--SKIPIF-- + +--FILE-- +map( + ['a', 'b', 'c', 'd'], + fn($item) => match ($item) { + 'a' => 1, + 'b' => "two", + 'c' => [3, 3, 3], + 'd' => null, + } + ); + + echo json_encode($results) . "\n"; + $pool->close(); +}); +?> +--EXPECT-- +[1,"two",[3,3,3],null] diff --git a/tests/thread_pool/030-await_same_future_twice.phpt b/tests/thread_pool/030-await_same_future_twice.phpt new file mode 100644 index 00000000..9cf7a95a --- /dev/null +++ b/tests/thread_pool/030-await_same_future_twice.phpt @@ -0,0 +1,28 @@ +--TEST-- +ThreadPool: awaiting the same future twice yields the same result +--SKIPIF-- + +--FILE-- +submit(fn() => 42); + + $a = await($f); + $b = await($f); + + echo "$a,$b\n"; + $pool->close(); +}); +?> +--EXPECT-- +42,42 diff --git a/tests/thread_pool/031-closure_with_complex_op_array_features.phpt b/tests/thread_pool/031-closure_with_complex_op_array_features.phpt new file mode 100644 index 00000000..c0f631c1 --- /dev/null +++ b/tests/thread_pool/031-closure_with_complex_op_array_features.phpt @@ -0,0 +1,53 @@ +--TEST-- +ThreadPool: closures with try/catch, static vars and dynamic_func_defs transfer cleanly +--FILE-- +submit($work, 2); +$f2 = $pool->submit($work, 5); +$f3 = $pool->submit($work, 9); + +var_dump(await($f1)); +var_dump(await($f2)); +var_dump(await($f3)); + +$pool->close(); + +echo "end\n"; + +?> +--EXPECT-- +int(44) +int(6) +int(10) +end diff --git a/tests/thread_pool/032-map_on_closed_pool.phpt b/tests/thread_pool/032-map_on_closed_pool.phpt new file mode 100644 index 00000000..e00eefa1 --- /dev/null +++ b/tests/thread_pool/032-map_on_closed_pool.phpt @@ -0,0 +1,33 @@ +--TEST-- +ThreadPool: map() on a closed pool throws ThreadPoolException +--SKIPIF-- + +--FILE-- +close(); + + try { + $pool->map([1, 2, 3], fn($x) => $x * 2); + echo "ERROR: should have thrown\n"; + } catch (ThreadPoolException $e) { + echo "caught: ", $e->getMessage(), "\n"; + } +}); + +?> +--EXPECT-- +caught: ThreadPool is closed diff --git a/thread-pool.md b/thread-pool.md new file mode 100644 index 00000000..766551bc --- /dev/null +++ b/thread-pool.md @@ -0,0 +1,213 @@ +# ThreadPool для TrueAsync — Исследование + +## Мотивация + +Текущий `Thread::start()` создаёт новый OS-поток на каждый вызов: +TSRM init → request startup → выполнение → shutdown → ts_free_thread. +Дорого для мелких задач. ThreadPool держит пул предварительно инициализированных worker-потоков + общую очередь задач. + +Существующий `zend_async_task_t` + `uv_queue_work` (libuv_reactor.c:2197) — только для C-уровня. +В worker-потоке libuv **нет доступа к PHP/Zend API** (комментарий: "No PHP/Zend API access is allowed here"). +ThreadPool — для **PHP-замыканий**. + +--- + +## Что уже есть в TrueAsync + +### Thread API (ext/async/thread.c) +- **Snapshot mechanism**: глубокое копирование closure + captured vars в persistent memory (pemalloc) + - `async_thread_snapshot_create(entry, bootloader)` — создаёт snapshot + - Arena-based bump allocator для op_arrays + - `async_thread_transfer_zval()` — копирование zval в pemalloc (parent → child) + - `async_thread_load_zval()` — копирование из pemalloc в emalloc (child → parent) +- **Worker lifecycle**: `async_thread_tsrm_init()` → `async_thread_request_startup(snapshot)` → execute → transfer result → `notify_parent()` → `async_thread_request_shutdown()` → `ts_free_thread()` +- **Bootloader**: опциональная closure, выполняемая до основной + +### Task Queue (Zend/zend_async_API.h) +```c +struct _zend_async_task_s { + zend_async_event_t base; + zend_async_task_run_t run; // C function pointer +}; +``` +- `ZEND_ASYNC_QUEUE_TASK(task)` → `uv_queue_work()` → libuv thread pool +- `zend_async_thread_pool_register(module, allow_override, queue_task_fn)` — одна глобальная регистрация + +### Pool pattern (ext/async/pool.c) +- `zend_async_pool_t` с min/max size, factory/destructor callbacks +- `circular_buffer_t idle` + `zend_async_callbacks_vector_t waiters` +- acquire/release + circuit breaker + healthcheck timer +- Back-pressure: корутина suspend'ится через `ZEND_ASYNC_SUSPEND()` + +### Channel (ext/async/channel.h) +- Thread-safe channels с `circular_buffer_t buffer` +- `waiting_receivers` / `waiting_senders` +- Завязаны на корутины — **не подходят для worker-потоков** без event loop + +### Event + notification (libuv_reactor.c) +- `uv_async_t` для кросс-потокового уведомления event loop +- `notify_parent()` на thread event вызывает `uv_async_send()` +- Parent loop callback обрабатывает результат + +--- + +## Лучшие практики из других языков + +### Java ThreadPoolExecutor +- **core/max threads**: core всегда живые, extra потоки убиваются по idle timeout +- **Pluggable queue**: `LinkedBlockingQueue` (unbounded), `ArrayBlockingQueue(N)` (bounded), `SynchronousQueue` (direct handoff) +- **Rejection policy**: AbortPolicy (throw), CallerRunsPolicy, DiscardPolicy, DiscardOldestPolicy +- `submit(Callable)` → `Future` с `get()`, `cancel()`, `isDone()` +- `shutdown()` / `shutdownNow()` / `awaitTermination(timeout)` + +### Python concurrent.futures.ThreadPoolExecutor +- `submit(fn, *args, **kwargs)` → `Future` +- **initializer** callback — один раз на worker thread (для per-thread setup) +- Unbounded `SimpleQueue`, single shared, no work-stealing +- `shutdown(wait=True, cancel_futures=False)` +- Context manager: `with ThreadPoolExecutor() as e:` → auto shutdown + +### Rust tokio +- Separation: **async workers** (fixed) + **blocking pool** (elastic, 0→512) +- `spawn_blocking(closure)` → `JoinHandle` (awaitable Future) +- Per-core run queues с work-stealing +- `JoinSet` для управления группой задач + +### Go ants +- `pool.Submit(func(){})` — fire-and-forget (no Future) +- Bounded pool + blocking on full +- Worker recycling: idle workers убираются по таймеру +- `pool.Tune(size)` — динамическое изменение размера +- `pool.Release()` — graceful shutdown + +### C# ThreadPool + TPL +- `Task.Run(() => ...)` → `Task` (awaitable) +- Hill-climbing auto-sizing (feedback-based, ~500ms intervals) +- `CancellationToken` — cooperative cancellation через всю цепочку +- Global FIFO queue + per-thread LIFO queues с work-stealing + +--- + +## Ключевые решения (принятые) + +| Вопрос | Решение | +|--------|---------| +| API submit | `submit(Closure $task, mixed ...$args): Future` — variadic args | +| Fire-and-forget | Нет, только `submit()` с Future | +| Worker creation | Переиспользовать механизм из thread.c (детали позже) | +| SharedPool | Отдельный этап, потребуется API | +| Очередь | Atomic trylock (два флага: head_busy, tail_busy) + mutex fallback + condvar для сна idle workers | +| Небуферизированный канал | Нет, ThreadChannel всегда буферизированный (capacity >= 1) | +| Уведомление потоков | uv_async_send (не pthread_cond_t), т.к. потоки всегда имеют event loop | +| Thread-safe канал | `async_thread_channel_t` — отдельная структура, реализует `zend_async_channel_t` | +| PHP-класс канала | `Async\ThreadChannel` | +| Структура данных | Отдельна от PHP-объекта, в persistent memory, atomic refcount для передачи между потоками | +| Модули | `thread_channel.h/.c` и `thread_pool.h/.c` — отдельные файлы в ext/async | +| Порядок реализации | Сначала ThreadChannel, потом ThreadPool (зависит от него) | +| Уведомление waiters | `zend_async_trigger_event_t` — обёртка вокруг uv_async_send, мультишот, per-thread per-channel | +| Back-pressure | Корутина подписывается на trigger event + SUSPEND. Другой поток дёргает trigger() для пробуждения | +| Deadlock detection | Канал помнит потоки-писатели. recv() без writer'ов → throw (реализовать позже) | + +## Текущий статус back-pressure (WIP) + +Реализован механизм: корутина создаёт trigger event через `ensure_trigger()`, подписывается через +`zend_async_resume_when()` + `zend_coroutine_event_callback_t`, вызывает `ZEND_ASYNC_SUSPEND()`. +Другая сторона вызывает `fire_all_triggers()` который делает `trigger->trigger()` (= uv_async_send). + +**Проблема**: в однопоточном тесте sender suspend'ится на полном буфере, receiver забирает данные +и вызывает fire_all_triggers, но sender не просыпается. Нужно отладить: +1. Проверить что trigger event корректно зарегистрирован в sender_triggers HashTable +2. Проверить что callback корутины корректно подписан на trigger event (add_callback) +3. Проверить что ZEND_ASYNC_CALLBACKS_NOTIFY на trigger event вызывает callback sender'а +4. Возможно проблема в lifecycle callback — dispose/ref_count + +--- + +## Базовый принцип: разделение структуры и PHP-объекта + +`thread_pool_t` — самостоятельная C-структура в **persistent memory** (pemalloc). +PHP-объект `Async\ThreadPool` — тонкая обёртка, хранящая только указатель. + +``` +┌─────────────────────────┐ +│ thread_pool_object_t │ ← emalloc (per-thread PHP object) +│ ┌───────────────────┐ │ +│ │ thread_pool_t *pool ──────► ┌──────────────────┐ +│ └───────────────────┘ │ │ thread_pool_t │ ← pemalloc (persistent, shared) +│ zend_object std │ │ ref_count (atomic)│ +└─────────────────────────┘ │ queue │ + │ workers │ +┌─────────────────────────┐ │ ... │ +│ thread_pool_object_t │ └──────────────────┘ +│ (другой PHP-поток) │ ▲ +│ pool ───────────────────────────────┘ +└─────────────────────────┘ +``` + +Это даёт: +- **Передача между потоками**: при transfer PHP-объекта копируется только указатель + `ref_count++` +- **Независимость от GC**: время жизни pool определяется ref_count, не PHP GC +- **Множественный доступ**: разные PHP-потоки создают свои обёртки вокруг одного pool + +Аналог: как `pdo_dbh_t` живёт отдельно от `zend_object` в PDO, или как `zend_async_pool_t` отделён от PHP wrapper. + +--- + +## Компоненты для детальной проработки + +### 0. ThreadChannel (`thread_channel.h/.c`, `Async\ThreadChannel`) +Thread-safe канал — фундамент для ThreadPool. Реализует `zend_async_channel_t`. +Структура `async_thread_channel_t`: +- `zend_async_channel_t channel` — ABI base +- `circular_buffer_t buffer` — ring buffer в pemalloc +- `pthread_mutex_t mutex` — защита буфера (наносекунды, не блокирует event loop) +- `int32_t capacity` — всегда >= 1 +- Маппинг `thread_id → uv_async_t*` — для уведомления event loop нужного потока +- Список waiters (ожидающие корутины + их thread handle) + +Механика: +- send(): mutex lock → пишем в буфер → если есть waiter, uv_async_send его потоку → unlock +- receive(): mutex lock → если есть данные, забираем → unlock. Если пуст — регистрируем waiter, unlock, SUSPEND +- Данные в буфере через `async_thread_transfer_zval` (pemalloc) +- uv_async_t создаётся лениво при первом обращении потока к каналу, мультишот + +### 1. Task Queue (использует ThreadChannel) + +### 2. Worker Thread Lifecycle +Persistent PHP thread с TSRM. Init один раз, цикл на очереди. +Ключевое: request_startup/shutdown один раз на жизнь worker-а, не на каждую задачу. +Вопросы: как именно загружать closure из snapshot в уже running request. + +### 3. Task Submission + Result Delivery +Submit: snapshot closure → enqueue → signal worker. +Result: worker transfer result в pemalloc → uv_async_send → parent load → resolve Future. +Вопросы: back-pressure (нельзя блокировать event loop), per-task notification handle. + +### 4. Pool Management +min/max threads, scaling, idle reaping. +Вопросы: когда создавать новые workers, когда убивать idle. + +### 5. PHP Class +Constructor, submit, shutdown, shutdownNow, awaitTermination. +Properties: activeWorkerCount, queuedTaskCount, isShutdown. + +### 6. SharedPool +Sharing across PHP threads. Persistent memory + atomic refcount. +Отдельный этап. + +### 7. Error Handling +Bailout, exception transfer, worker crash recovery. +Отдельный этап. + +--- + +## Ключевые файлы + +| Файл | Что переиспользовать | +|------|---------------------| +| `ext/async/thread.c` | snapshot create/destroy, TSRM init, closure execution, zval transfer/load | +| `ext/async/libuv_reactor.c:2197` | `libuv_queue_task` — паттерн uv_async notification | +| `ext/async/pool.c` | PHP object handlers pattern (create_object/dtor/free) | +| `ext/async/future.c` | Future creation + resolve | +| `Zend/zend_async_API.h:1019` | `zend_async_task_t` struct | +| `Zend/zend_async_API.h:397` | `zend_async_queue_task_t` typedef | diff --git a/thread.c b/thread.c new file mode 100644 index 00000000..4515c677 --- /dev/null +++ b/thread.c @@ -0,0 +1,2548 @@ +/* ++----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to version 3.01 of the PHP license, | + | that is bundled with this package in the file LICENSE, and is | + | available through the world-wide-web at the following url: | + | https://www.php.net/license/3_01.txt | + | If you did not receive a copy of the PHP license and are unable to | + | obtain it through the world-wide-web, please send a note to | + | license@php.net so we can mail you a copy immediately. | + +----------------------------------------------------------------------+ + | Author: Edmond | + +----------------------------------------------------------------------+ +*/ + +#include "thread.h" +#include "thread_arginfo.h" +#include "coroutine.h" +#include "exceptions.h" +#include "libuv_reactor.h" +#include "php_async.h" +#include "php.h" +#include "php_main.h" +#include "SAPI.h" +#include "zend.h" +#include "zend_API.h" +#include "zend_compile.h" +#include "zend_autoload.h" +#include "zend_hash.h" +#include "zend_exceptions.h" +#include "zend_attributes.h" +#include "zend_vm.h" +#include "zend_interfaces.h" +#include "zend_closures.h" +#include "zend_common.h" +#include "zend_map_ptr.h" +#include "main/SAPI.h" + +/////////////////////////////////////////////////////////// +/// 0. Deep copy — arena-based op_array copy +/////////////////////////////////////////////////////////// + +/** + * Deep copy context: bump allocator + xlat table for pointer deduplication. + * All copies go into persistent arena memory so child threads can access + * them safely. The arena is freed as a whole on snapshot destroy. + * + * Using a bump allocator instead of individual pemalloc calls guarantees + * that opcodes and literals are in the same memory region, keeping + * RT_CONSTANT int32_t relative offsets within range. + */ +#define THREAD_COPY_ARENA_BLOCK_SIZE (64 * 1024) + +typedef struct { + thread_copy_arena_block_t *current_block; + HashTable xlat; /* old_ptr → new_ptr for deduplication */ +} thread_copy_ctx_t; + +static void thread_copy_ctx_init(thread_copy_ctx_t *ctx) +{ + thread_copy_arena_block_t *block = pemalloc( + sizeof(thread_copy_arena_block_t) + THREAD_COPY_ARENA_BLOCK_SIZE, 1); + block->prev = NULL; + block->size = THREAD_COPY_ARENA_BLOCK_SIZE; + block->offset = 0; + ctx->current_block = block; + zend_hash_init(&ctx->xlat, 128, NULL, NULL, 1); +} + +static zend_always_inline void *thread_copy_arena_alloc(thread_copy_ctx_t *ctx, size_t size) +{ + size = ZEND_MM_ALIGNED_SIZE(size); + thread_copy_arena_block_t *block = ctx->current_block; + + if (EXPECTED(block->offset + size <= block->size)) { + void *ptr = block->data + block->offset; + block->offset += size; + return ptr; + } + + /* Current block exhausted — allocate a new one */ + size_t new_size = block->size * 2; + if (UNEXPECTED(new_size < size)) { + new_size = size; + } + thread_copy_arena_block_t *new_block = pemalloc( + sizeof(thread_copy_arena_block_t) + new_size, 1); + new_block->prev = block; + new_block->size = new_size; + new_block->offset = size; + ctx->current_block = new_block; + return new_block->data; +} + +static void thread_copy_ctx_destroy(thread_copy_ctx_t *ctx) +{ + zend_hash_destroy(&ctx->xlat); + /* Arena blocks are NOT freed here — ownership transfers to the snapshot */ +} + +static void thread_copy_arena_free(thread_copy_arena_block_t *block) +{ + while (block) { + thread_copy_arena_block_t *prev = block->prev; + pefree(block, 1); + block = prev; + } +} + +/* Copy memory into arena, register in xlat (update semantics — overwrites existing) */ +static void *thread_persist_copy_xlat(thread_copy_ctx_t *ctx, const void *src, size_t size) +{ + void *dst = thread_copy_arena_alloc(ctx, size); + memcpy(dst, src, size); + zend_hash_index_update_ptr(&ctx->xlat, async_ptr_to_index((void *)src), dst); + return dst; +} + +/* Copy memory into arena, register in xlat (add semantics — fails silently on duplicate) */ +static void *thread_persist_copy(thread_copy_ctx_t *ctx, const void *src, size_t size) +{ + void *dst = thread_copy_arena_alloc(ctx, size); + memcpy(dst, src, size); + zend_hash_index_add_ptr(&ctx->xlat, async_ptr_to_index((void *)src), dst); + return dst; +} + +/* Look up a previously copied pointer */ +static zend_always_inline void *thread_xlat_get(const thread_copy_ctx_t *ctx, const void *old_ptr) +{ + return zend_hash_index_find_ptr(&ctx->xlat, async_ptr_to_index((void *)old_ptr)); +} + + +/* Canonical uninitialized bucket for empty hash tables */ +static const uint32_t thread_uninitialized_bucket[-HT_MIN_MASK] = + {HT_INVALID_IDX, HT_INVALID_IDX}; + +/* Copy a string into persistent memory with interned+permanent flags */ +static zend_string *thread_copy_string(thread_copy_ctx_t *ctx, const zend_string *str) +{ + if (!str) { + return NULL; + } + + zend_string *new_str = thread_xlat_get(ctx, str); + if (new_str) { + return new_str; + } + + new_str = thread_persist_copy_xlat(ctx, str, _ZSTR_STRUCT_SIZE(ZSTR_LEN(str))); + zend_string_hash_val(new_str); + GC_SET_REFCOUNT(new_str, 2); + + const uint32_t flags = GC_STRING + | (ZSTR_IS_VALID_UTF8(new_str) ? IS_STR_VALID_UTF8 : 0) + | ((IS_STR_INTERNED | IS_STR_PERMANENT) << GC_FLAGS_SHIFT); + GC_TYPE_INFO(new_str) = flags; + + return new_str; +} + +/* Forward declarations */ +static void thread_copy_zval(thread_copy_ctx_t *ctx, zval *z); +static void thread_copy_hash_table(thread_copy_ctx_t *ctx, HashTable *ht); +static zend_ast *thread_copy_ast(thread_copy_ctx_t *ctx, zend_ast *ast); +static void thread_copy_op_array(thread_copy_ctx_t *ctx, zval *zv); +static void thread_copy_op_array_ex(thread_copy_ctx_t *ctx, zend_op_array *op_array); +static void thread_copy_type(thread_copy_ctx_t *ctx, zend_type *type); +static HashTable *thread_copy_attributes(thread_copy_ctx_t *ctx, HashTable *attributes); + +/* {{{ thread_copy_hash_table — relocate HashTable internals to pemalloc */ +static void thread_copy_hash_table(thread_copy_ctx_t *ctx, HashTable *ht) +{ + HT_FLAGS(ht) |= HASH_FLAG_STATIC_KEYS; + ht->pDestructor = NULL; + ht->nInternalPointer = 0; + + if (HT_FLAGS(ht) & HASH_FLAG_UNINITIALIZED) { + HT_SET_DATA_ADDR(ht, &thread_uninitialized_bucket); + return; + } + + if (ht->nNumUsed == 0) { + ht->nTableMask = HT_MIN_MASK; + HT_SET_DATA_ADDR(ht, &thread_uninitialized_bucket); + HT_FLAGS(ht) |= HASH_FLAG_UNINITIALIZED; + return; + } + + if (HT_IS_PACKED(ht)) { + void *data = thread_persist_copy(ctx, HT_GET_DATA_ADDR(ht), HT_PACKED_USED_SIZE(ht)); + HT_SET_DATA_ADDR(ht, data); + } else { + void *data = thread_persist_copy(ctx, HT_GET_DATA_ADDR(ht), HT_USED_SIZE(ht)); + HT_SET_DATA_ADDR(ht, data); + } +} +/* }}} */ + +/* {{{ thread_copy_ast */ +static zend_ast *thread_copy_ast(thread_copy_ctx_t *ctx, zend_ast *ast) +{ + zend_ast *node; + + if (ast->kind == ZEND_AST_ZVAL || ast->kind == ZEND_AST_CONSTANT) { + zend_ast_zval *copy = thread_persist_copy(ctx, ast, sizeof(zend_ast_zval)); + thread_copy_zval(ctx, ©->val); + node = (zend_ast *) copy; + } else if (zend_ast_is_list(ast)) { + zend_ast_list *list = zend_ast_get_list(ast); + zend_ast_list *copy = thread_persist_copy(ctx, ast, + sizeof(zend_ast_list) - sizeof(zend_ast *) + sizeof(zend_ast *) * list->children); + for (uint32_t i = 0; i < list->children; i++) { + if (copy->child[i]) { + copy->child[i] = thread_copy_ast(ctx, copy->child[i]); + } + } + node = (zend_ast *) copy; + } else if (ast->kind == ZEND_AST_OP_ARRAY) { + zend_ast_op_array *copy = thread_persist_copy(ctx, ast, sizeof(zend_ast_op_array)); + zval z; + ZVAL_PTR(&z, copy->op_array); + thread_copy_op_array(ctx, &z); + copy->op_array = Z_PTR(z); + node = (zend_ast *) copy; + } else if (ast->kind == ZEND_AST_CALLABLE_CONVERT) { + zend_ast_fcc *copy = thread_persist_copy(ctx, ast, sizeof(zend_ast_fcc)); + copy->args = thread_copy_ast(ctx, copy->args); + node = (zend_ast *) copy; + } else { + const uint32_t children = zend_ast_get_num_children(ast); + node = thread_persist_copy(ctx, ast, zend_ast_size(children)); + for (uint32_t i = 0; i < children; i++) { + if (node->child[i]) { + node->child[i] = thread_copy_ast(ctx, node->child[i]); + } + } + } + + return node; +} +/* }}} */ + +/* {{{ thread_copy_zval */ +static void thread_copy_zval(thread_copy_ctx_t *ctx, zval *z) +{ + switch (Z_TYPE_P(z)) { + case IS_STRING: + Z_STR_P(z) = thread_copy_string(ctx, Z_STR_P(z)); + Z_TYPE_FLAGS_P(z) = 0; + break; + case IS_ARRAY: { + void *new_ptr = thread_xlat_get(ctx, Z_ARR_P(z)); + if (new_ptr) { + Z_ARR_P(z) = new_ptr; + Z_TYPE_FLAGS_P(z) = 0; + } else { + HashTable *ht = thread_persist_copy_xlat(ctx, Z_ARR_P(z), sizeof(zend_array)); + Z_ARR_P(z) = ht; + thread_copy_hash_table(ctx, ht); + if (HT_IS_PACKED(ht)) { + zval *zv; + ZEND_HASH_PACKED_FOREACH_VAL(ht, zv) { + thread_copy_zval(ctx, zv); + } ZEND_HASH_FOREACH_END(); + } else { + Bucket *p; + ZEND_HASH_MAP_FOREACH_BUCKET(ht, p) { + if (p->key) { + p->key = thread_copy_string(ctx, p->key); + } + thread_copy_zval(ctx, &p->val); + } ZEND_HASH_FOREACH_END(); + } + Z_TYPE_FLAGS_P(z) = 0; + GC_SET_REFCOUNT(Z_COUNTED_P(z), 2); + GC_ADD_FLAGS(Z_COUNTED_P(z), IS_ARRAY_IMMUTABLE); + } + break; + } + case IS_CONSTANT_AST: { + void *new_ptr = thread_xlat_get(ctx, Z_AST_P(z)); + if (new_ptr) { + Z_AST_P(z) = new_ptr; + Z_TYPE_FLAGS_P(z) = 0; + } else { + zend_ast_ref *old_ref = Z_AST_P(z); + Z_AST_P(z) = thread_persist_copy_xlat(ctx, Z_AST_P(z), sizeof(zend_ast_ref)); + thread_copy_ast(ctx, GC_AST(old_ref)); + Z_TYPE_FLAGS_P(z) = 0; + GC_SET_REFCOUNT(Z_COUNTED_P(z), 1); + GC_ADD_FLAGS(Z_COUNTED_P(z), GC_IMMUTABLE); + } + break; + } + case IS_PTR: + break; + default: + ZEND_ASSERT(Z_TYPE_P(z) < IS_STRING); + break; + } +} +/* }}} */ + +/* {{{ thread_copy_attributes */ +static HashTable *thread_copy_attributes(thread_copy_ctx_t *ctx, HashTable *attributes) +{ + HashTable *xlat = thread_xlat_get(ctx, attributes); + if (xlat) { + return xlat; + } + + /* Copy the HashTable struct first, then relocate internals on the copy, + * so the original HashTable stays untouched. */ + attributes = thread_persist_copy_xlat(ctx, attributes, sizeof(HashTable)); + thread_copy_hash_table(ctx, attributes); + + zval *v; + ZEND_HASH_PACKED_FOREACH_VAL(attributes, v) { + const zend_attribute *attr = Z_PTR_P(v); + zend_attribute *copy = thread_persist_copy_xlat(ctx, attr, ZEND_ATTRIBUTE_SIZE(attr->argc)); + + copy->name = thread_copy_string(ctx, copy->name); + copy->lcname = thread_copy_string(ctx, copy->lcname); + if (copy->validation_error) { + copy->validation_error = thread_copy_string(ctx, copy->validation_error); + } + + for (uint32_t i = 0; i < copy->argc; i++) { + if (copy->args[i].name) { + copy->args[i].name = thread_copy_string(ctx, copy->args[i].name); + } + thread_copy_zval(ctx, ©->args[i].value); + } + + ZVAL_PTR(v, copy); + } ZEND_HASH_FOREACH_END(); + + GC_SET_REFCOUNT(attributes, 2); + GC_TYPE_INFO(attributes) = GC_ARRAY | ((IS_ARRAY_IMMUTABLE|GC_NOT_COLLECTABLE) << GC_FLAGS_SHIFT); + + return attributes; +} +/* }}} */ + +/* {{{ thread_copy_type */ +static void thread_copy_type(thread_copy_ctx_t *ctx, zend_type *type) +{ + if (ZEND_TYPE_HAS_LIST(*type)) { + zend_type_list *list = ZEND_TYPE_LIST(*type); + list = thread_persist_copy_xlat(ctx, list, ZEND_TYPE_LIST_SIZE(list->num_types)); + ZEND_TYPE_FULL_MASK(*type) &= ~_ZEND_TYPE_ARENA_BIT; + ZEND_TYPE_SET_PTR(*type, list); + } + + zend_type *single_type; + ZEND_TYPE_FOREACH_MUTABLE(*type, single_type) { + if (ZEND_TYPE_HAS_LIST(*single_type)) { + thread_copy_type(ctx, single_type); + continue; + } + if (ZEND_TYPE_HAS_NAME(*single_type)) { + zend_string *type_name = ZEND_TYPE_NAME(*single_type); + type_name = thread_copy_string(ctx, type_name); + ZEND_TYPE_SET_PTR(*single_type, type_name); + /* Skip zend_accel_get_class_name_map_ptr — not safe without OPcache */ + } + } ZEND_TYPE_FOREACH_END(); +} +/* }}} */ + +/* {{{ thread_copy_op_array_ex — deep copy op_array internals */ +static void thread_copy_op_array_ex(thread_copy_ctx_t *ctx, zend_op_array *op_array) +{ + const zval *orig_literals = NULL; + + /* refcount: detach from parent's shared counter without modifying it */ + op_array->refcount = NULL; + + if (op_array->function_name) { + op_array->function_name = thread_copy_string(ctx, op_array->function_name); + } + + if (op_array->scope) { + zend_class_entry *scope = thread_xlat_get(ctx, op_array->scope); + if (scope) { + op_array->scope = scope; + } + + if (op_array->prototype) { + zend_function *ptr = thread_xlat_get(ctx, op_array->prototype); + if (ptr) { + op_array->prototype = ptr; + } + } + + /* Check if opcodes were already copied (shared method) */ + zend_op *persist_ptr = thread_xlat_get(ctx, op_array->opcodes); + if (persist_ptr) { + op_array->opcodes = persist_ptr; + if (op_array->static_variables) { + op_array->static_variables = thread_xlat_get(ctx, op_array->static_variables); + ZEND_ASSERT(op_array->static_variables != NULL); + } + if (op_array->literals) { + op_array->literals = thread_xlat_get(ctx, op_array->literals); + ZEND_ASSERT(op_array->literals != NULL); + } + if (op_array->filename) { + op_array->filename = thread_xlat_get(ctx, op_array->filename); + ZEND_ASSERT(op_array->filename != NULL); + } + if (op_array->arg_info) { + zend_arg_info *arg_info = op_array->arg_info; + if (op_array->fn_flags & ZEND_ACC_HAS_RETURN_TYPE) { + arg_info--; + } + arg_info = thread_xlat_get(ctx, arg_info); + ZEND_ASSERT(arg_info != NULL); + if (op_array->fn_flags & ZEND_ACC_HAS_RETURN_TYPE) { + arg_info++; + } + op_array->arg_info = arg_info; + } + if (op_array->live_range) { + op_array->live_range = thread_xlat_get(ctx, op_array->live_range); + ZEND_ASSERT(op_array->live_range != NULL); + } + if (op_array->doc_comment) { + op_array->doc_comment = thread_xlat_get(ctx, op_array->doc_comment); + } + if (op_array->attributes) { + op_array->attributes = thread_xlat_get(ctx, op_array->attributes); + ZEND_ASSERT(op_array->attributes != NULL); + } + if (op_array->try_catch_array) { + op_array->try_catch_array = thread_xlat_get(ctx, op_array->try_catch_array); + ZEND_ASSERT(op_array->try_catch_array != NULL); + } + if (op_array->vars) { + op_array->vars = thread_xlat_get(ctx, op_array->vars); + ZEND_ASSERT(op_array->vars != NULL); + } + if (op_array->dynamic_func_defs) { + op_array->dynamic_func_defs = thread_xlat_get(ctx, op_array->dynamic_func_defs); + ZEND_ASSERT(op_array->dynamic_func_defs != NULL); + } + return; + } + } else { + op_array->prototype = NULL; + } + + /* static_variables — copy the HashTable struct first, then relocate + * internals on the copy, so the original HashTable stays untouched. */ + if (op_array->static_variables) { + Bucket *p; + op_array->static_variables = thread_persist_copy_xlat(ctx, op_array->static_variables, sizeof(HashTable)); + thread_copy_hash_table(ctx, op_array->static_variables); + ZEND_HASH_MAP_FOREACH_BUCKET(op_array->static_variables, p) { + ZEND_ASSERT(p->key != NULL); + p->key = thread_copy_string(ctx, p->key); + thread_copy_zval(ctx, &p->val); + } ZEND_HASH_FOREACH_END(); + GC_SET_REFCOUNT(op_array->static_variables, 2); + GC_TYPE_INFO(op_array->static_variables) = GC_ARRAY | ((IS_ARRAY_IMMUTABLE|GC_NOT_COLLECTABLE) << GC_FLAGS_SHIFT); + } + + /* literals */ + if (op_array->literals) { + orig_literals = op_array->literals; + zval *p = thread_persist_copy_xlat(ctx, op_array->literals, + sizeof(zval) * op_array->last_literal); + const zval *end = p + op_array->last_literal; + op_array->literals = p; + while (p < end) { + thread_copy_zval(ctx, p); + p++; + } + } + + /* opcodes */ + { + zend_op *new_opcodes = thread_persist_copy_xlat(ctx, op_array->opcodes, sizeof(zend_op) * op_array->last); + zend_op *opline = new_opcodes; + const zend_op *end = new_opcodes + op_array->last; + + for (; opline < end; opline++) { +#if ZEND_USE_ABS_CONST_ADDR + if (opline->op1_type == IS_CONST) { + opline->op1.zv = (zval*)((char*)opline->op1.zv + ((char*)op_array->literals - (char*)orig_literals)); + if (opline->opcode == ZEND_SEND_VAL + || opline->opcode == ZEND_SEND_VAL_EX + || opline->opcode == ZEND_QM_ASSIGN) { + zend_vm_set_opcode_handler_ex(opline, 1 << Z_TYPE_P(opline->op1.zv), 0, 0); + } + } + if (opline->op2_type == IS_CONST) { + opline->op2.zv = (zval*)((char*)opline->op2.zv + ((char*)op_array->literals - (char*)orig_literals)); + } +#else + if (opline->op1_type == IS_CONST) { + opline->op1.constant = + (char*)(op_array->literals + + ((zval*)((char*)(op_array->opcodes + (opline - new_opcodes)) + + (int32_t)opline->op1.constant) - orig_literals)) - + (char*)opline; + if (opline->opcode == ZEND_SEND_VAL + || opline->opcode == ZEND_SEND_VAL_EX + || opline->opcode == ZEND_QM_ASSIGN) { + zend_vm_set_opcode_handler_ex(opline, 0, 0, 0); + } + } + if (opline->op2_type == IS_CONST) { + opline->op2.constant = + (char*)(op_array->literals + + ((zval*)((char*)(op_array->opcodes + (opline - new_opcodes)) + + (int32_t)opline->op2.constant) - orig_literals)) - + (char*)opline; + } +#endif +#if ZEND_USE_ABS_JMP_ADDR + if (op_array->fn_flags & ZEND_ACC_DONE_PASS_TWO) { + switch (opline->opcode) { + case ZEND_JMP: + case ZEND_FAST_CALL: + opline->op1.jmp_addr = &new_opcodes[opline->op1.jmp_addr - op_array->opcodes]; + break; + case ZEND_JMPZ: + case ZEND_JMPNZ: + case ZEND_JMPZ_EX: + case ZEND_JMPNZ_EX: + case ZEND_JMP_SET: + case ZEND_COALESCE: + case ZEND_FE_RESET_R: + case ZEND_FE_RESET_RW: + case ZEND_ASSERT_CHECK: + case ZEND_JMP_NULL: + case ZEND_BIND_INIT_STATIC_OR_JMP: + case ZEND_JMP_FRAMELESS: + opline->op2.jmp_addr = &new_opcodes[opline->op2.jmp_addr - op_array->opcodes]; + break; + case ZEND_CATCH: + if (!(opline->extended_value & ZEND_LAST_CATCH)) { + opline->op2.jmp_addr = &new_opcodes[opline->op2.jmp_addr - op_array->opcodes]; + } + break; + case ZEND_FE_FETCH_R: + case ZEND_FE_FETCH_RW: + case ZEND_SWITCH_LONG: + case ZEND_SWITCH_STRING: + case ZEND_MATCH: + break; + } + } +#endif + if (opline->opcode == ZEND_OP_DATA + && (opline-1)->opcode == ZEND_DECLARE_ATTRIBUTED_CONST) { + zval *literal = RT_CONSTANT(opline, opline->op1); + HashTable *attributes = Z_PTR_P(literal); + attributes = thread_copy_attributes(ctx, attributes); + ZVAL_PTR(literal, attributes); + } + + /* Reset opcode handler to the standard VM handler. If opcache JIT + * has compiled the source op_array, opline->handler was replaced + * by a JIT stub with the source op_array's addresses baked in — + * executing that stub on our copy (different literals, fresh + * run_time_cache, child-thread map_ptr_base) crashes. Re-specialize + * from the VM handler table so the copy runs on the interpreter. */ + zend_vm_set_opcode_handler(opline); + } + + op_array->opcodes = new_opcodes; + } + + /* filename */ + if (op_array->filename) { + op_array->filename = thread_copy_string(ctx, op_array->filename); + } + + /* arg_info */ + if (op_array->arg_info) { + zend_arg_info *arg_info = op_array->arg_info; + uint32_t num_args = op_array->num_args; + + if (op_array->fn_flags & ZEND_ACC_HAS_RETURN_TYPE) { + arg_info--; + num_args++; + } + if (op_array->fn_flags & ZEND_ACC_VARIADIC) { + num_args++; + } + arg_info = thread_persist_copy_xlat(ctx, arg_info, sizeof(zend_arg_info) * num_args); + for (uint32_t i = 0; i < num_args; i++) { + if (arg_info[i].name) { + arg_info[i].name = thread_copy_string(ctx, arg_info[i].name); + } + thread_copy_type(ctx, &arg_info[i].type); + } + if (op_array->fn_flags & ZEND_ACC_HAS_RETURN_TYPE) { + arg_info++; + } + op_array->arg_info = arg_info; + } + + /* live_range */ + if (op_array->live_range) { + op_array->live_range = thread_persist_copy_xlat(ctx, op_array->live_range, + sizeof(zend_live_range) * op_array->last_live_range); + } + + /* doc_comment — always copy */ + if (op_array->doc_comment) { + op_array->doc_comment = thread_copy_string(ctx, op_array->doc_comment); + } + + /* attributes */ + if (op_array->attributes) { + op_array->attributes = thread_copy_attributes(ctx, op_array->attributes); + } + + /* try_catch_array */ + if (op_array->try_catch_array) { + op_array->try_catch_array = thread_persist_copy_xlat(ctx, op_array->try_catch_array, + sizeof(zend_try_catch_element) * op_array->last_try_catch); + } + + /* vars */ + if (op_array->vars) { + op_array->vars = thread_persist_copy_xlat(ctx, op_array->vars, + sizeof(zend_string*) * op_array->last_var); + for (int i = 0; i < op_array->last_var; i++) { + op_array->vars[i] = thread_copy_string(ctx, op_array->vars[i]); + } + } + + /* dynamic_func_defs (nested closures/lambdas) */ + if (op_array->num_dynamic_func_defs) { + op_array->dynamic_func_defs = thread_persist_copy_xlat(ctx, op_array->dynamic_func_defs, + sizeof(zend_function *) * op_array->num_dynamic_func_defs); + for (uint32_t i = 0; i < op_array->num_dynamic_func_defs; i++) { + zval tmp; + ZVAL_PTR(&tmp, op_array->dynamic_func_defs[i]); + thread_copy_op_array(ctx, &tmp); + op_array->dynamic_func_defs[i] = Z_PTR(tmp); + } + } +} +/* }}} */ + +/* {{{ thread_copy_op_array — copy a standalone user function */ +static void thread_copy_op_array(thread_copy_ctx_t *ctx, zval *zv) +{ + zend_op_array *op_array = Z_PTR_P(zv); + ZEND_ASSERT(op_array->type == ZEND_USER_FUNCTION); + + zend_op_array *old = thread_xlat_get(ctx, op_array); + if (old) { + Z_PTR_P(zv) = old; + return; + } + + op_array = Z_PTR_P(zv) = thread_persist_copy_xlat(ctx, Z_PTR_P(zv), sizeof(zend_op_array)); + thread_copy_op_array_ex(ctx, op_array); + + op_array->fn_flags |= ZEND_ACC_IMMUTABLE; + ZEND_MAP_PTR_INIT(op_array->run_time_cache, NULL); + if (op_array->static_variables) { + ZEND_MAP_PTR_INIT(op_array->static_variables_ptr, NULL); + } +} +/* }}} */ + +/////////////////////////////////////////////////////////// +/// 0.5. Zval transfer — copy runtime values between threads +/////////////////////////////////////////////////////////// + +/** + * Transfer context: persistent-memory deep copy of runtime zvals. + * Uses xlat table to preserve object/array identity (same pointer + * in source → same pointer in destination) and handle cycles. + */ +#define THREAD_TRANSFER_MAX_DEPTH 512 + +#define THREAD_DEPTH_CHECK(ctx, ret) do { \ + if (UNEXPECTED((ctx)->depth >= THREAD_TRANSFER_MAX_DEPTH)) { \ + (ctx)->error = "Maximum nesting depth exceeded during thread data transfer"; \ + return ret; \ + } \ + (ctx)->depth++; \ +} while (0) + +#define THREAD_DEPTH_RELEASE(ctx) (ctx)->depth-- + +static void thread_transfer_ctx_init(thread_transfer_ctx_t *ctx) +{ + zend_hash_init(&ctx->xlat, 32, NULL, NULL, 0); + ctx->depth = 0; + ctx->defer_release = NULL; + ctx->error = NULL; +} + +static void thread_transfer_ctx_destroy(thread_transfer_ctx_t *ctx) +{ + if (ctx->defer_release) { + zend_hash_destroy(ctx->defer_release); + efree(ctx->defer_release); + ctx->defer_release = NULL; + } + zend_hash_destroy(&ctx->xlat); +} + +static void *thread_transfer_xlat_get(const thread_transfer_ctx_t *ctx, const void *ptr) +{ + return zend_hash_index_find_ptr(&ctx->xlat, async_ptr_to_index((void *)ptr)); +} + +static void thread_transfer_xlat_put(thread_transfer_ctx_t *ctx, const void *old_ptr, void *new_ptr) +{ + zend_hash_index_update_ptr(&ctx->xlat, async_ptr_to_index((void *)old_ptr), new_ptr); +} + +/* Forward declarations */ +static void thread_transfer_zval_inner(thread_transfer_ctx_t *ctx, zval *dst, const zval *src); +static HashTable *thread_transfer_hash_table(thread_transfer_ctx_t *ctx, const HashTable *src); +static zend_object *thread_transfer_object(thread_transfer_ctx_t *ctx, const zend_object *src); +static void thread_release_transferred_zval(zval *z); + +/* Copy a zend_string into persistent memory */ +static zend_string *thread_transfer_string(thread_transfer_ctx_t *ctx, const zend_string *str) +{ + zend_string *existing = thread_transfer_xlat_get(ctx, str); + if (existing) { + GC_ADDREF(existing); + return existing; + } + + zend_string *copy = zend_string_init(ZSTR_VAL(str), ZSTR_LEN(str), 1); + thread_transfer_xlat_put(ctx, str, copy); + + return copy; +} + +/* {{{ thread_transfer_hash_table — deep copy a HashTable into persistent memory */ +static HashTable *thread_transfer_hash_table(thread_transfer_ctx_t *ctx, const HashTable *src) +{ + THREAD_DEPTH_CHECK(ctx, NULL); + + HashTable *existing = thread_transfer_xlat_get(ctx, src); + if (existing) { + GC_ADDREF(existing); + THREAD_DEPTH_RELEASE(ctx); + return existing; + } + + HashTable *dst = pemalloc(sizeof(HashTable), 1); + thread_transfer_xlat_put(ctx, src, dst); + + const uint32_t count = zend_hash_num_elements(src); + + zend_hash_init(dst, count, NULL, NULL, 1); + + if (count == 0) { + THREAD_DEPTH_RELEASE(ctx); + return dst; + } + + if (HT_IS_PACKED(src)) { + zval *val; + zend_ulong idx; + ZEND_HASH_PACKED_FOREACH_KEY_VAL((HashTable *)src, idx, val) { + zval copy; + thread_transfer_zval_inner(ctx, ©, val); + if (UNEXPECTED(ctx->error)) { + thread_release_transferred_zval(©); + break; + } + zend_hash_index_add(dst, idx, ©); + } ZEND_HASH_FOREACH_END(); + } else { + zend_string *key; + zend_ulong idx; + zval *val; + ZEND_HASH_MAP_FOREACH_KEY_VAL((HashTable *)src, idx, key, val) { + zval copy; + thread_transfer_zval_inner(ctx, ©, val); + if (UNEXPECTED(ctx->error)) { + thread_release_transferred_zval(©); + break; + } + if (key) { + zend_string *pkey = thread_transfer_string(ctx, key); + zend_hash_add(dst, pkey, ©); + zend_string_release(pkey); + } else { + zend_hash_index_add(dst, idx, ©); + } + } ZEND_HASH_FOREACH_END(); + } + + THREAD_DEPTH_RELEASE(ctx); + return dst; +} +/* }}} */ + +/* Default transfer: pemalloc object of alloc_size (0 = auto), copy properties. + * Can be passed to transfer_obj handler as default_fn. */ +static zend_object *thread_transfer_object_default( + const zend_object *src, thread_transfer_ctx_t *ctx, size_t alloc_size) +{ + const zend_class_entry *ce = src->ce; + const uint32_t prop_count = ce->default_properties_count; + const int offset = src->handlers->offset; + const size_t obj_size = sizeof(zend_object) + zend_object_properties_size(ce); + + if (alloc_size == 0) { + /* Auto-detect: offset (wrapper prefix) + zend_object + properties */ + alloc_size = offset + obj_size; + } + + /* alloc_size is the full wrapper size (including offset bytes before zend_object) */ + char *base = pecalloc(1, alloc_size, 1); + + zend_object *dst = (zend_object *)(base + offset); + + /* Copy zend_object + properties_table */ + memcpy(dst, src, obj_size); + GC_SET_REFCOUNT(dst, 1); + + /* Store offset for release — extra_flags is unused in transit */ + dst->extra_flags = (uint32_t) offset; + + /* Repurpose fields for transit */ + dst->ce = (zend_class_entry *) thread_transfer_string(ctx, ce->name); + dst->handlers = (const zend_object_handlers *)(uintptr_t) prop_count; + dst->properties = NULL; + + /* Deep-copy each property zval in the copy */ + for (uint32_t i = 0; i < prop_count; i++) { + zval *prop = &dst->properties_table[i]; + if (Z_TYPE_P(prop) != IS_UNDEF) { + zval transferred; + thread_transfer_zval_inner(ctx, &transferred, prop); + if (UNEXPECTED(ctx->error)) { + /* Mark remaining properties as UNDEF so release won't + * try to free uninitialized memory */ + for (uint32_t j = i; j < prop_count; j++) { + ZVAL_UNDEF(&dst->properties_table[j]); + } + break; + } + ZVAL_COPY_VALUE(prop, &transferred); + } + } + + return dst; +} + +static zend_object *thread_transfer_object(thread_transfer_ctx_t *ctx, const zend_object *src) +{ + THREAD_DEPTH_CHECK(ctx, NULL); + + zend_object *existing = thread_transfer_xlat_get(ctx, src); + if (existing) { + GC_ADDREF(existing); + THREAD_DEPTH_RELEASE(ctx); + return existing; + } + + /* Custom transfer handler (e.g. thread-safe shared objects) */ + if (src->handlers->transfer_obj) { + zend_object *dst = src->handlers->transfer_obj( + (zend_object *) src, ctx, ZEND_OBJECT_TRANSFER, + (zend_object_transfer_default_fn) thread_transfer_object_default); + if (dst) { + thread_transfer_xlat_put(ctx, src, dst); + } + THREAD_DEPTH_RELEASE(ctx); + return dst; + } + + /* Dynamic properties (stdClass, __set) are not supported */ + if (src->properties && zend_hash_num_elements(src->properties) > 0) { + ctx->error = "Cannot transfer object with dynamic properties between threads"; + THREAD_DEPTH_RELEASE(ctx); + return NULL; + } + + zend_object *dst = thread_transfer_object_default(src, ctx, 0); + thread_transfer_xlat_put(ctx, src, dst); + + THREAD_DEPTH_RELEASE(ctx); + return dst; +} +/* }}} */ + +/* {{{ thread_transfer_zval_inner — recursive deep copy of a single zval */ +static zend_always_inline void thread_transfer_zval_inner(thread_transfer_ctx_t *ctx, zval *dst, const zval *src) +{ + switch (Z_TYPE_P(src)) { + case IS_UNDEF: + ZVAL_UNDEF(dst); + break; + + case IS_NULL: + ZVAL_NULL(dst); + break; + + case IS_FALSE: + ZVAL_FALSE(dst); + break; + + case IS_TRUE: + ZVAL_TRUE(dst); + break; + + case IS_LONG: + ZVAL_LONG(dst, Z_LVAL_P(src)); + break; + + case IS_DOUBLE: + ZVAL_DOUBLE(dst, Z_DVAL_P(src)); + break; + + case IS_STRING: { + zend_string *copy = thread_transfer_string(ctx, Z_STR_P(src)); + ZVAL_STR(dst, copy); + break; + } + + case IS_ARRAY: { + HashTable *copy = thread_transfer_hash_table(ctx, Z_ARRVAL_P(src)); + if (UNEXPECTED(copy == NULL)) { + ZVAL_NULL(dst); + return; + } + ZVAL_ARR(dst, copy); + break; + } + + case IS_OBJECT: { + zend_object *copy = thread_transfer_object(ctx, Z_OBJ_P(src)); + if (UNEXPECTED(copy == NULL)) { + ZVAL_NULL(dst); + return; + } + ZVAL_OBJ(dst, copy); + break; + } + + case IS_RESOURCE: + ctx->error = "Cannot transfer a resource between threads"; + ZVAL_NULL(dst); + break; + + case IS_REFERENCE: + ctx->error = "Cannot transfer a reference between threads"; + ZVAL_NULL(dst); + break; + + default: + ctx->error = "Cannot transfer zval of unsupported type between threads"; + ZVAL_NULL(dst); + break; + } +} +/* }}} */ + +/** + * Copy a zval into persistent memory for cross-thread transfer. + * + * The result is a deep copy in pemalloc'd memory. Objects and arrays + * that appear in multiple places share identity (same copy). + * Cyclic references are handled via xlat table. + * + * @param dst Destination zval (will be overwritten) + * @param src Source zval (unchanged) + */ +void async_thread_transfer_zval(zval *dst, const zval *src) +{ + thread_transfer_ctx_t ctx; + thread_transfer_ctx_init(&ctx); + thread_transfer_zval_inner(&ctx, dst, src); + + if (UNEXPECTED(ctx.error)) { + /* Transfer failed — free any partially-allocated persistent memory + * before throwing. We must throw AFTER cleanup because + * zend_throw_error triggers zend_bailout() when there is no + * active execute_data (which is the case during thread transfer). */ + const char *error = ctx.error; + thread_release_transferred_zval(dst); + ZVAL_UNDEF(dst); + thread_transfer_ctx_destroy(&ctx); + zend_throw_error(NULL, "%s", error); + return; + } + + thread_transfer_ctx_destroy(&ctx); +} + +/** + * Load a persistent zval into the current thread's emalloc heap. + * Creates a proper refcounted copy that the current thread owns. + * + * @param dst Destination zval (current thread, emalloc'd) + * @param src Source zval (persistent memory from thread_transfer_zval) + */ +static void thread_load_zval_inner(thread_transfer_ctx_t *ctx, zval *dst, const zval *src); +static HashTable *thread_load_hash_table(thread_transfer_ctx_t *ctx, const HashTable *src); +static zend_object *thread_load_object(thread_transfer_ctx_t *ctx, const zend_object *src); + +static zend_string *thread_load_string(thread_transfer_ctx_t *ctx, const zend_string *str) +{ + zend_string *existing = thread_transfer_xlat_get(ctx, str); + if (existing) { + return zend_string_copy(existing); + } + + zend_string *copy = zend_string_init(ZSTR_VAL(str), ZSTR_LEN(str), 0); + thread_transfer_xlat_put(ctx, str, copy); + + return copy; +} + +static HashTable *thread_load_hash_table(thread_transfer_ctx_t *ctx, const HashTable *src) +{ + THREAD_DEPTH_CHECK(ctx, NULL); + + HashTable *existing = thread_transfer_xlat_get(ctx, src); + if (existing) { + GC_ADDREF(existing); + THREAD_DEPTH_RELEASE(ctx); + return existing; + } + + HashTable *dst = emalloc(sizeof(HashTable)); + thread_transfer_xlat_put(ctx, src, dst); + + const uint32_t count = zend_hash_num_elements(src); + zend_hash_init(dst, count, NULL, ZVAL_PTR_DTOR, 0); + + if (count == 0) { + return dst; + } + + zend_string *key; + zend_ulong idx; + zval *val; + ZEND_HASH_FOREACH_KEY_VAL((HashTable *)src, idx, key, val) { + zval copy; + thread_load_zval_inner(ctx, ©, val); + if (key) { + zend_string *ekey = thread_load_string(ctx, key); + zend_hash_add(dst, ekey, ©); + zend_string_release(ekey); + } else { + zend_hash_index_add(dst, idx, ©); + } + } ZEND_HASH_FOREACH_END(); + + THREAD_DEPTH_RELEASE(ctx); + return dst; +} + +/* Default load: resolve class, create emalloc object via create_object, copy properties. + * alloc_size is ignored for load (object created via zend_objects_new/create_object). */ +static zend_object *thread_load_object_default( + const zend_object *src, thread_transfer_ctx_t *ctx, size_t alloc_size) +{ + (void) alloc_size; + + /* Read transit fields: ce = class_name, handlers = prop_count */ + const zend_string *class_name = (const zend_string *) src->ce; + const uint32_t src_prop_count = (uint32_t)(uintptr_t) src->handlers; + + /* Resolve class by name via autoload */ + zend_string *lookup_name = zend_string_init( + ZSTR_VAL(class_name), ZSTR_LEN(class_name), 0); + zend_class_entry *ce = zend_lookup_class(lookup_name); + zend_string_release(lookup_name); + + if (UNEXPECTED(ce == NULL)) { + if (!EG(exception)) { + zend_throw_error(NULL, + "Cannot load transferred object: class \"%s\" not found", + ZSTR_VAL(class_name)); + } + zend_object *fallback = zend_objects_new(zend_standard_class_def); + object_properties_init(fallback, zend_standard_class_def); + return fallback; + } + + /* Create a normal emalloc'd object (uses create_object if defined) */ + zend_object *dst; + if (ce->create_object) { + dst = ce->create_object(ce); + } else { + dst = zend_objects_new(ce); + object_properties_init(dst, ce); + } + + /* Copy declared properties from transit object */ + const uint32_t prop_count = MIN(src_prop_count, + (uint32_t) ce->default_properties_count); + + for (uint32_t i = 0; i < prop_count; i++) { + const zval *prop = &src->properties_table[i]; + if (Z_TYPE_P(prop) != IS_UNDEF) { + zval copy; + thread_load_zval_inner(ctx, ©, prop); + zval_ptr_dtor(&dst->properties_table[i]); + ZVAL_COPY_VALUE(&dst->properties_table[i], ©); + } + } + + return dst; +} + +static zend_object *thread_load_object(thread_transfer_ctx_t *ctx, const zend_object *src) +{ + THREAD_DEPTH_CHECK(ctx, NULL); + + zend_object *existing = thread_transfer_xlat_get(ctx, src); + if (existing) { + GC_ADDREF(existing); + THREAD_DEPTH_RELEASE(ctx); + return existing; + } + + /* Read transit class name to resolve ce and check for transfer_obj handler */ + const zend_string *class_name = (const zend_string *) src->ce; + zend_string *lookup_name = zend_string_init( + ZSTR_VAL(class_name), ZSTR_LEN(class_name), 0); + zend_class_entry *ce = zend_lookup_class(lookup_name); + zend_string_release(lookup_name); + + if (ce && ce->default_object_handlers && ce->default_object_handlers->transfer_obj) { + zend_object *dst = ce->default_object_handlers->transfer_obj( + (zend_object *) src, ctx, ZEND_OBJECT_LOAD, + (zend_object_transfer_default_fn) thread_load_object_default); + if (dst) { + thread_transfer_xlat_put(ctx, src, dst); + } + THREAD_DEPTH_RELEASE(ctx); + return dst; + } + + zend_object *dst = thread_load_object_default(src, ctx, 0); + thread_transfer_xlat_put(ctx, src, dst); + + THREAD_DEPTH_RELEASE(ctx); + return dst; +} + +static zend_always_inline void thread_load_zval_inner(thread_transfer_ctx_t *ctx, zval *dst, const zval *src) +{ + switch (Z_TYPE_P(src)) { + case IS_UNDEF: + ZVAL_UNDEF(dst); + break; + + case IS_NULL: + ZVAL_NULL(dst); + break; + + case IS_FALSE: + ZVAL_FALSE(dst); + break; + + case IS_TRUE: + ZVAL_TRUE(dst); + break; + + case IS_LONG: + ZVAL_LONG(dst, Z_LVAL_P(src)); + break; + + case IS_DOUBLE: + ZVAL_DOUBLE(dst, Z_DVAL_P(src)); + break; + + case IS_STRING: { + zend_string *copy = thread_load_string(ctx, Z_STR_P(src)); + ZVAL_STR(dst, copy); + break; + } + + case IS_ARRAY: { + HashTable *copy = thread_load_hash_table(ctx, Z_ARR_P(src)); + ZVAL_ARR(dst, copy); + break; + } + + case IS_OBJECT: { + zend_object *copy = thread_load_object(ctx, Z_OBJ_P(src)); + ZVAL_OBJ(dst, copy); + break; + } + + default: + ZVAL_NULL(dst); + break; + } +} + +/** + * Load a persistent zval into the current thread's emalloc heap. + * + * @param dst Destination zval (emalloc, current thread) + * @param src Source zval (pemalloc, from async_thread_transfer_zval) + */ +void async_thread_load_zval(zval *dst, const zval *src) +{ + thread_transfer_ctx_t ctx; + thread_transfer_ctx_init(&ctx); + thread_load_zval_inner(&ctx, dst, src); + thread_transfer_ctx_destroy(&ctx); +} + +/* {{{ Release — free persistent zvals created by async_thread_transfer_zval */ + +static void thread_release_transferred_hash_table(HashTable *ht); +static void thread_release_transferred_object(zend_object *obj); + +static void thread_release_transferred_zval(zval *z) +{ + switch (Z_TYPE_P(z)) { + case IS_STRING: + zend_string_release(Z_STR_P(z)); + break; + + case IS_ARRAY: + thread_release_transferred_hash_table(Z_ARR_P(z)); + break; + + case IS_OBJECT: + thread_release_transferred_object(Z_OBJ_P(z)); + break; + + default: + break; + } +} + +static void thread_release_transferred_hash_table(HashTable *ht) +{ + if (ht->nNumUsed == 0 && ht->nNumOfElements == 0 && ht->nTableSize == 0) { + return; + } + + if (GC_DELREF(ht) > 0) { + return; + } + + zval *val; + ZEND_HASH_FOREACH_VAL(ht, val) { + thread_release_transferred_zval(val); + } ZEND_HASH_FOREACH_END(); + + zend_hash_destroy(ht); + pefree(ht, 1); +} + +static void thread_release_transferred_object(zend_object *obj) +{ + if (GC_DELREF(obj) > 0) { + return; + } + + /* Read transit fields */ + const uint32_t prop_count = (uint32_t)(uintptr_t) obj->handlers; + zend_string *class_name = (zend_string *) obj->ce; + + for (uint32_t i = 0; i < prop_count; i++) { + thread_release_transferred_zval(&obj->properties_table[i]); + } + + zend_string_release(class_name); + + /* Free from base of allocation (offset stored in extra_flags by transfer_default) */ + const uint32_t offset = obj->extra_flags; + pefree((char *)obj - offset, 1); +} + +void async_thread_release_transferred_zval(zval *z) +{ + thread_release_transferred_zval(z); + ZVAL_UNDEF(z); +} + +/* }}} */ + +/* {{{ Recursion helpers for transfer_obj handlers living in Zend core. + * Registered into the zend_async_thread_*_fn function pointers via + * zend_async_thread_pool_register so handlers (e.g. for WeakReference and + * WeakMap in Zend/zend_weakrefs.c) can recursively deep-copy child zvals + * within an existing ctx, preserving identity and handling cycles through + * the shared xlat table. */ +void async_thread_transfer_zval_ctx( + zend_async_thread_transfer_ctx_t *ctx, zval *dst, const zval *src) +{ + thread_transfer_zval_inner(ctx, dst, src); +} + +void async_thread_load_zval_ctx( + zend_async_thread_transfer_ctx_t *ctx, zval *dst, const zval *src) +{ + thread_load_zval_inner(ctx, dst, src); +} + +void async_thread_xlat_put_ctx( + zend_async_thread_transfer_ctx_t *ctx, const void *src, void *dst) +{ + thread_transfer_xlat_put(ctx, src, dst); +} + +void async_thread_defer_release_ctx( + zend_async_thread_transfer_ctx_t *ctx, zval *z) +{ + if (ctx->defer_release == NULL) { + ctx->defer_release = emalloc(sizeof(HashTable)); + zend_hash_init(ctx->defer_release, 8, NULL, ZVAL_PTR_DTOR, 0); + } + zend_hash_next_index_insert(ctx->defer_release, z); + ZVAL_UNDEF(z); +} +/* }}} */ + +/////////////////////////////////////////////////////////// +/// 1. Snapshot — transfer callable between threads +/////////////////////////////////////////////////////////// + +/** + * Deep-copy a callable into persistent memory. + * Copies op_array via thread_copy_op_array and transfers + * 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) +{ + const zend_op_array *src_op = &fcall->fci_cache.function_handler->op_array; + + /* Deep copy the op_array into arena */ + zval tmp; + ZVAL_PTR(&tmp, (void *) src_op); + thread_copy_op_array(ctx, &tmp); + dst->func = Z_PTR(tmp); + + /* Transfer captured variables (use ($a, $b)) into persistent memory. + * bound_vars use pemalloc (not arena) because they're released individually + * in thread_release_closure_copy via async_thread_release_transferred_zval. */ + HashTable *static_vars = ZEND_MAP_PTR_GET(src_op->static_variables_ptr); + if (!static_vars) { + static_vars = src_op->static_variables; + } + + if (static_vars && zend_hash_num_elements(static_vars) > 0) { + dst->bound_vars = pemalloc(sizeof(HashTable), 1); + zend_hash_init(dst->bound_vars, zend_hash_num_elements(static_vars), NULL, NULL, 1); + + /* Shared transfer ctx across all bound vars: xlat preserves identity + * so two captured variables pointing to the same object end up as the + * same transit copy (otherwise each var would be transferred with its + * own ctx → independent copies on the receiving side). */ + thread_transfer_ctx_t transfer_ctx; + thread_transfer_ctx_init(&transfer_ctx); + + zend_string *key; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(static_vars, key, val) { + zval transferred; + thread_transfer_zval_inner(&transfer_ctx, &transferred, val); + if (UNEXPECTED(transfer_ctx.error)) { + thread_release_transferred_zval(&transferred); + break; + } + zend_string *pkey = zend_string_dup(key, 1); + zend_hash_add(dst->bound_vars, pkey, &transferred); + zend_string_release(pkey); + } ZEND_HASH_FOREACH_END(); + + if (UNEXPECTED(transfer_ctx.error)) { + /* Clean up partially transferred bound vars */ + zval *v; + ZEND_HASH_FOREACH_VAL(dst->bound_vars, v) { + thread_release_transferred_zval(v); + } ZEND_HASH_FOREACH_END(); + zend_hash_destroy(dst->bound_vars); + pefree(dst->bound_vars, 1); + dst->bound_vars = NULL; + thread_transfer_ctx_destroy(&transfer_ctx); + zend_throw_error(NULL, "%s", transfer_ctx.error); + return; + } + + thread_transfer_ctx_destroy(&transfer_ctx); + } else { + dst->bound_vars = NULL; + } +} + +/** + * Free resources of a copied closure (bound vars only; + * op_array is freed via arena). + */ +static void thread_release_closure_copy(async_thread_closure_copy_t *copy) +{ + if (copy->bound_vars) { + zval *val; + ZEND_HASH_FOREACH_VAL(copy->bound_vars, val) { + async_thread_release_transferred_zval(val); + } ZEND_HASH_FOREACH_END(); + /* zend_hash_destroy releases string keys itself */ + zend_hash_destroy(copy->bound_vars); + pefree(copy->bound_vars, 1); + } +} + +/** + * Create a snapshot: deep-copy closures into arena memory. + */ +async_thread_snapshot_t *async_thread_snapshot_create(const zend_fcall_t *entry, const zend_fcall_t *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); + + if (bootloader != NULL && !EG(exception)) { + thread_copy_callable(&ctx, bootloader, &snapshot->bootloader); + } + + /* Store arena block list in snapshot — needed even for the failure path + * so that destroy can release any op_array data already copied. */ + snapshot->arena_blocks = ctx.current_block; + thread_copy_ctx_destroy(&ctx); + + /* Bound-var transfer or op_array copy may have thrown (e.g. a captured + * object's transfer_obj handler refused). In that case the snapshot is + * partially initialized — destroy it and propagate the exception. */ + if (UNEXPECTED(EG(exception))) { + async_thread_snapshot_destroy(snapshot); + return NULL; + } + + return snapshot; +} + +/** + * Free snapshot resources. + */ +void async_thread_snapshot_destroy(async_thread_snapshot_t *snapshot) +{ + thread_release_closure_copy(&snapshot->entry); + + if (snapshot->bootloader.func != NULL) { + thread_release_closure_copy(&snapshot->bootloader); + } + + /* Free all arena blocks at once */ + thread_copy_arena_free(snapshot->arena_blocks); + snapshot->arena_blocks = NULL; + + pefree(snapshot, 1); +} + +/** + * Create a RemoteException wrapping a loaded remote exception object. + */ +/** + * Create a ThreadTransferException with message and optional previous exception. + * Consumes the previous exception reference (OBJ_RELEASE after setting). + */ +static zend_object *thread_create_transfer_exception( + const char *message, zend_object *previous) +{ + zval exception_zv; + object_init_ex(&exception_zv, async_ce_thread_transfer_exception); + + zval msg_zv; + ZVAL_STRING(&msg_zv, message); + zend_update_property_ex(async_ce_thread_transfer_exception, + Z_OBJ(exception_zv), ZSTR_KNOWN(ZEND_STR_MESSAGE), &msg_zv); + zval_ptr_dtor(&msg_zv); + + if (previous) { + zend_exception_set_previous(Z_OBJ(exception_zv), previous); + } + + return Z_OBJ(exception_zv); +} + +static zend_object *thread_wrap_remote_exception( + zend_object *remote_obj, const char *remote_class_name) +{ + zval wrapper_zval; + object_init_ex(&wrapper_zval, async_ce_remote_exception); + zend_object *wrapper = Z_OBJ(wrapper_zval); + + /* Set message from remote exception */ + zval rv; + zval *remote_message = zend_read_property_ex( + remote_obj->ce, remote_obj, ZSTR_KNOWN(ZEND_STR_MESSAGE), 1, &rv); + if (remote_message && Z_TYPE_P(remote_message) == IS_STRING) { + zend_update_property_ex(async_ce_remote_exception, wrapper, + ZSTR_KNOWN(ZEND_STR_MESSAGE), remote_message); + } + + /* Set code from remote exception */ + zval *remote_code = zend_read_property_ex( + remote_obj->ce, remote_obj, ZSTR_KNOWN(ZEND_STR_CODE), 1, &rv); + if (remote_code && Z_TYPE_P(remote_code) == IS_LONG) { + zval code_zv; + ZVAL_LONG(&code_zv, Z_LVAL_P(remote_code)); + zend_update_property_ex(async_ce_remote_exception, wrapper, + ZSTR_KNOWN(ZEND_STR_CODE), &code_zv); + } + + /* Store the original remote exception */ + zval remote_exception_zval; + ZVAL_OBJ_COPY(&remote_exception_zval, remote_obj); + zend_update_property(async_ce_remote_exception, wrapper, + "remoteException", sizeof("remoteException") - 1, &remote_exception_zval); + zval_ptr_dtor(&remote_exception_zval); + + /* Store the remote class name */ + zend_update_property_string(async_ce_remote_exception, wrapper, + "remoteClass", sizeof("remoteClass") - 1, remote_class_name); + + return wrapper; +} + +/** + * Load thread result/exception from persistent memory into parent's emalloc. + * Called from reactor's notify callback in the parent thread. + */ +void async_thread_load_result(zend_async_thread_event_t *event) +{ + ZEND_ASYNC_EVENT_CLR_EXCEPTION_HANDLED(&event->base); + + /* Handle bailout from child thread (fatal error, OOM, exit()) */ + if (UNEXPECTED(event->context && event->context->bailout_error_message != NULL)) { + event->exception = thread_create_transfer_exception( + event->context->bailout_error_message, NULL); + pefree(event->context->bailout_error_message, 1); + event->context->bailout_error_message = NULL; + ZEND_THREAD_SET_RESULT_LOADED(event); + return; + } + + /* Load exception and wrap in RemoteException */ + if (event->exception != NULL) { + /* remote_class_name lives in persistent memory (transit format: ce = class_name) */ + const char *remote_class_name = ZSTR_VAL((zend_string *) event->exception->ce); + + zval persistent_exception, loaded_exception; + ZVAL_OBJ(&persistent_exception, event->exception); + async_thread_load_zval(&loaded_exception, &persistent_exception); + + if (UNEXPECTED(EG(exception))) { + zend_object *loading_exception = EG(exception); + GC_ADDREF(loading_exception); + zend_clear_exception(); + + zend_string *message = zend_strpprintf(0, + "Failed to load remote exception of class \"%s\"", remote_class_name); + event->exception = thread_create_transfer_exception( + ZSTR_VAL(message), loading_exception); + zend_string_release(message); + + zval_ptr_dtor(&loaded_exception); + async_thread_release_transferred_zval(&persistent_exception); + } else { + event->exception = thread_wrap_remote_exception( + Z_OBJ(loaded_exception), remote_class_name); + zval_ptr_dtor(&loaded_exception); + async_thread_release_transferred_zval(&persistent_exception); + } + } + + /* Load result */ + if (!Z_ISUNDEF(event->result)) { + zval loaded_result; + async_thread_load_zval(&loaded_result, &event->result); + + if (UNEXPECTED(EG(exception))) { + zend_object *loading_exception = EG(exception); + GC_ADDREF(loading_exception); + zend_clear_exception(); + + async_thread_release_transferred_zval(&event->result); + zval_ptr_dtor(&loaded_result); + event->exception = thread_create_transfer_exception( + "Failed to load thread result into parent thread", loading_exception); + ZEND_THREAD_SET_RESULT_LOADED(event); + return; + } + + async_thread_release_transferred_zval(&event->result); + ZVAL_COPY_VALUE(&event->result, &loaded_result); + ZEND_ASYNC_EVENT_SET_ZVAL_RESULT(&event->base); + } + + ZEND_THREAD_SET_RESULT_LOADED(event); +} + +/////////////////////////////////////////////////////////// +/// 2. Thread lifecycle — PHP request in child thread +/////////////////////////////////////////////////////////// + +/** + * Initialize TSRM for the child thread. + * Must be called BEFORE any zend_try block (EG(bailout) is not available yet). + * After this call, EG()/SG()/PG() macros work correctly. + */ +void async_thread_tsrm_init(void) +{ +#ifdef ZTS + ts_resource(0); + TSRMLS_CACHE_UPDATE(); + + /* Update TSRMLS cache in the SAPI module (e.g. php.exe on Windows). + * Without this, SG()/EG() calls from SAPI callbacks would use + * an uninitialized per-module thread-local cache. */ + if (sapi_module.thread_init) { + sapi_module.thread_init(); + } +#endif +} + +int async_thread_request_startup(const async_thread_snapshot_t *snapshot) +{ +#ifdef ZTS + PG(expose_php) = 0; + PG(auto_globals_jit) = 1; + + if (UNEXPECTED(php_request_startup() == FAILURE)) { + ts_free_thread(); + return FAILURE; + } + + /* Suppress HTTP-specific behavior */ + PG(during_request_startup) = 0; + SG(sapi_started) = 0; + SG(headers_sent) = 1; + SG(request_info).no_headers = 1; + + /* Set script filename from entry closure for error reporting */ + if (snapshot != NULL && snapshot->entry.func && snapshot->entry.func->filename) { + SG(request_info).path_translated = estrndup( + ZSTR_VAL(snapshot->entry.func->filename), + ZSTR_LEN(snapshot->entry.func->filename)); + } + + /* $_SERVER and $_ENV are initialized lazily (auto_globals_jit=1). + * Do NOT force zend_is_auto_global() here — it triggers interned string + * creation via a global (non-TLS) function pointer that may race with + * the main thread's request shutdown. */ + + return SUCCESS; +#else + return FAILURE; +#endif +} + +void async_thread_request_shutdown(void) +{ +#ifdef ZTS + /* Free path_translated before shutdown destroys emalloc heap */ + if (SG(request_info).path_translated) { + efree(SG(request_info).path_translated); + SG(request_info).path_translated = NULL; + } + php_request_shutdown(NULL); +#endif +} + +/* Closure struct layout — defined in zend_closures.c, not exported */ +typedef struct { + zend_object std; + zend_function func; + zval this_ptr; + zend_class_entry *called_scope; + zif_handler orig_internal_handler; +} async_zend_closure_t; + +/** + * Copy op_array internals from persistent/arena memory into emalloc. + * After this call, the op_array is fully self-contained in emalloc + * and the persistent source (snapshot arena) can be freed. + */ +static void op_array_to_emalloc(zend_op_array *op_array) +{ + /* refcount — own copy */ + uint32_t *rc = emalloc(sizeof(uint32_t)); + *rc = 1; + op_array->refcount = rc; + + /* function_name — already addref'd by zend_create_closure, but points to + * persistent string. Create emalloc copy. */ + if (op_array->function_name) { + zend_string *old = op_array->function_name; + op_array->function_name = zend_string_init(ZSTR_VAL(old), ZSTR_LEN(old), 0); + zend_string_release(old); /* release the addref from zend_create_closure */ + } + + /* filename */ + if (op_array->filename) { + op_array->filename = zend_string_init( + ZSTR_VAL(op_array->filename), ZSTR_LEN(op_array->filename), 0); + } + + /* opcodes + literals + * + * Under !ZEND_USE_ABS_CONST_ADDR (64-bit) RT_CONSTANT stores the literal + * as an int32_t offset from the opline. emalloc may place independent + * allocations several GB apart, so we cannot call emalloc separately for + * opcodes and literals — the offset would overflow and the VM would + * read from unrelated memory. Mirror pass_two()'s layout: allocate one + * block with opcodes first (16-byte aligned) and literals immediately + * after. destroy_op_array() already knows this layout via the + * ZEND_ACC_DONE_PASS_TWO flag, which we leave set. */ + const zval *orig_literals = op_array->literals; + const zend_op *orig_opcodes = op_array->opcodes; + + if (op_array->opcodes) { + const size_t opcodes_size = + ZEND_MM_ALIGNED_SIZE_EX(sizeof(zend_op) * op_array->last, 16); + const size_t literals_size = sizeof(zval) * op_array->last_literal; + + zend_op *new_opcodes = (zend_op *) emalloc(opcodes_size + literals_size); + memcpy(new_opcodes, orig_opcodes, sizeof(zend_op) * op_array->last); + + zval *new_literals = NULL; + if (op_array->last_literal) { + new_literals = (zval *) ((char *) new_opcodes + opcodes_size); + for (uint32_t i = 0; i < op_array->last_literal; i++) { + ZVAL_COPY(&new_literals[i], &orig_literals[i]); + } + } + op_array->literals = new_literals; + +#if ZEND_USE_ABS_CONST_ADDR + for (uint32_t i = 0; i < op_array->last; i++) { + zend_op *opline = &new_opcodes[i]; + if (opline->op1_type == IS_CONST) { + opline->op1.zv = (zval *)((char *)opline->op1.zv + + ((char *)new_literals - (char *)orig_literals)); + } + if (opline->op2_type == IS_CONST) { + opline->op2.zv = (zval *)((char *)opline->op2.zv + + ((char *)new_literals - (char *)orig_literals)); + } + } +#else + for (uint32_t i = 0; i < op_array->last; i++) { + zend_op *opline = &new_opcodes[i]; + const zend_op *old_opline = &orig_opcodes[i]; + if (opline->op1_type == IS_CONST) { + zval *old_literal = (zval *)((char *) old_opline + + (int32_t) opline->op1.constant); + zval *new_literal = new_literals + (old_literal - orig_literals); + opline->op1.constant = (uint32_t)((char *) new_literal - (char *) opline); + } + if (opline->op2_type == IS_CONST) { + zval *old_literal = (zval *)((char *) old_opline + + (int32_t) opline->op2.constant); + zval *new_literal = new_literals + (old_literal - orig_literals); + opline->op2.constant = (uint32_t)((char *) new_literal - (char *) opline); + } + } +#endif + +#if ZEND_USE_ABS_JMP_ADDR + for (uint32_t i = 0; i < op_array->last; i++) { + zend_op *opline = &new_opcodes[i]; + switch (opline->opcode) { + case ZEND_JMP: + case ZEND_FAST_CALL: + opline->op1.jmp_addr = &new_opcodes[opline->op1.jmp_addr - orig_opcodes]; + break; + case ZEND_JMPZ: + case ZEND_JMPNZ: + case ZEND_JMPZ_EX: + case ZEND_JMPNZ_EX: + case ZEND_JMP_SET: + case ZEND_COALESCE: + case ZEND_FE_RESET_R: + case ZEND_FE_RESET_RW: + case ZEND_ASSERT_CHECK: + case ZEND_JMP_NULL: + case ZEND_BIND_INIT_STATIC_OR_JMP: + case ZEND_JMP_FRAMELESS: + opline->op2.jmp_addr = &new_opcodes[opline->op2.jmp_addr - orig_opcodes]; + break; + case ZEND_CATCH: + if (!(opline->extended_value & ZEND_LAST_CATCH)) { + opline->op2.jmp_addr = &new_opcodes[opline->op2.jmp_addr - orig_opcodes]; + } + break; + default: + break; + } + } +#endif + op_array->opcodes = new_opcodes; + } + + /* arg_info */ + if (op_array->arg_info) { + zend_arg_info *src = op_array->arg_info; + uint32_t num_args = op_array->num_args; + if (op_array->fn_flags & ZEND_ACC_HAS_RETURN_TYPE) { + src--; + num_args++; + } + if (op_array->fn_flags & ZEND_ACC_VARIADIC) { + num_args++; + } + zend_arg_info *new_info = safe_emalloc(num_args, sizeof(zend_arg_info), 0); + memcpy(new_info, src, sizeof(zend_arg_info) * num_args); + for (uint32_t i = 0; i < num_args; i++) { + if (new_info[i].name) { + new_info[i].name = zend_string_init( + ZSTR_VAL(new_info[i].name), ZSTR_LEN(new_info[i].name), 0); + } + } + if (op_array->fn_flags & ZEND_ACC_HAS_RETURN_TYPE) { + new_info++; + } + op_array->arg_info = new_info; + } + + /* vars */ + if (op_array->vars) { + zend_string **new_vars = safe_emalloc(op_array->last_var, sizeof(zend_string *), 0); + for (int i = 0; i < op_array->last_var; i++) { + new_vars[i] = zend_string_init( + ZSTR_VAL(op_array->vars[i]), ZSTR_LEN(op_array->vars[i]), 0); + } + op_array->vars = new_vars; + } + + /* live_range */ + if (op_array->live_range) { + zend_live_range *new_lr = safe_emalloc(op_array->last_live_range, sizeof(zend_live_range), 0); + memcpy(new_lr, op_array->live_range, sizeof(zend_live_range) * op_array->last_live_range); + op_array->live_range = new_lr; + } + + /* try_catch_array */ + if (op_array->try_catch_array) { + zend_try_catch_element *new_tc = safe_emalloc(op_array->last_try_catch, sizeof(zend_try_catch_element), 0); + memcpy(new_tc, op_array->try_catch_array, sizeof(zend_try_catch_element) * op_array->last_try_catch); + op_array->try_catch_array = new_tc; + } + + /* doc_comment */ + if (op_array->doc_comment) { + op_array->doc_comment = zend_string_init( + ZSTR_VAL(op_array->doc_comment), ZSTR_LEN(op_array->doc_comment), 0); + } + + /* Keep ZEND_ACC_DONE_PASS_TWO set: destroy_op_array() uses it to know + * that literals are embedded in the opcodes allocation and skips the + * separate efree(literals) call. */ +} + +void async_thread_create_closure( + const async_thread_closure_copy_t *copy, zval *closure_zv) +{ + ZEND_ASSERT(copy->func != NULL); + + zend_function func; + memcpy(&func, copy->func, sizeof(zend_op_array)); + func.op_array.fn_flags &= ~ZEND_ACC_IMMUTABLE; + + /* Set refcount to NULL: zend_create_closure skips increment, + * and destroy_op_array returns early without freeing pemalloc'd data. */ + func.op_array.refcount = NULL; + + /* Load bound variables from persistent memory into child's emalloc. + * Shared load ctx: same reason as on the transfer side — preserves + * identity across bound vars (same transit pointer → same loaded object). */ + HashTable *loaded_vars = NULL; + if (copy->bound_vars) { + loaded_vars = zend_new_array( + zend_hash_num_elements(copy->bound_vars)); + + thread_transfer_ctx_t load_ctx; + thread_transfer_ctx_init(&load_ctx); + + zend_string *key; + zval *val; + ZEND_HASH_FOREACH_STR_KEY_VAL(copy->bound_vars, key, val) { + zval loaded; + thread_load_zval_inner(&load_ctx, &loaded, val); + zend_string *local_key = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 0); + zend_hash_add(loaded_vars, local_key, &loaded); + zend_string_release(local_key); + } ZEND_HASH_FOREACH_END(); + + thread_transfer_ctx_destroy(&load_ctx); + ZEND_MAP_PTR_INIT(func.op_array.static_variables_ptr, loaded_vars); + } else { + ZEND_MAP_PTR_INIT(func.op_array.static_variables_ptr, NULL); + } + + /* Detach from persistent static_variables — we already loaded them + * into loaded_vars / static_variables_ptr above */ + func.op_array.static_variables = NULL; + + /* Let zend_create_closure allocate runtime cache itself */ + ZEND_MAP_PTR_INIT(func.op_array.run_time_cache, NULL); + func.op_array.fn_flags &= ~ZEND_ACC_HEAP_RT_CACHE; + + zend_create_closure(closure_zv, &func, NULL, NULL, NULL); + + /* zend_create_closure duplicates static_variables via zend_array_dup, + * so we must free our intermediate copy */ + if (loaded_vars) { + zend_array_destroy(loaded_vars); + } + +} + +/** + * Call a deep-copied closure in the child thread. + * + * Creates a Closure from the copy, executes it, and captures any exception + * immediately (before dtors that could trigger bailout). + * + * @param copy Deep-copied closure from snapshot + * @param event Thread event (receives exception on failure) + * @param retval Output: return value (UNDEF on exception or void return) + * @return true on success, false if exception was captured + */ +static bool thread_call_closure( + const async_thread_closure_copy_t *copy, + zend_async_thread_event_t *event, + zval *retval) +{ + zval closure_zv; + async_thread_create_closure(copy, &closure_zv); + + const zend_function *func = zend_get_closure_method_def(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). */ + zend_execute_data *frame = zend_vm_stack_push_call_frame( + ZEND_CALL_TOP_FUNCTION, (zend_function *) func, 0, NULL); + 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. */ + const bool has_exception = EG(exception) != NULL; + if (UNEXPECTED(has_exception)) { + zval exception_zval, transferred_exception; + ZVAL_OBJ_COPY(&exception_zval, EG(exception)); + zend_clear_exception(); + async_thread_transfer_zval(&transferred_exception, &exception_zval); + event->exception = Z_OBJ(transferred_exception); + zval_ptr_dtor(&exception_zval); + zval_ptr_dtor(retval); + ZVAL_UNDEF(retval); + } + + zval_ptr_dtor(&closure_zv); + return !has_exception; +} + +/** + * Capture bailout error message into the event. + * Safe to call after zend_catch — uses only pemalloc and PG(). + * + * @param fallback Pre-allocated fallback message (ownership transferred + * if PG(last_error_message) is NULL). Caller must set + * its pointer to NULL after this call to avoid double-free. + */ +static zend_always_inline void thread_capture_bailout(zend_async_thread_context_t *context, char **fallback) +{ + const zend_string *last_error = PG(last_error_message); + + if (last_error != NULL) { + context->bailout_error_message = pestrdup(ZSTR_VAL(last_error), 1); + } else { + context->bailout_error_message = *fallback; + *fallback = NULL; + } +} + +void async_thread_run(void *arg) +{ + zend_async_thread_context_t *context = (zend_async_thread_context_t *) arg; + zend_async_thread_event_t *event = context->event; /* may be NULL */ + const async_thread_snapshot_t *snapshot = context->snapshot; + bool request_started = false; + char *fallback_message = pestrdup("Unknown fatal error in child thread", 1); + + /* Record OS thread ID */ +#ifdef _WIN32 + zend_atomic_int64_store(&context->thread_id, (int64_t) GetCurrentThreadId()); +#else + zend_atomic_int64_store(&context->thread_id, (int64_t) pthread_self()); +#endif + + if (UNEXPECTED(snapshot == NULL && context->internal_entry == NULL)) { + goto notify; + } + + /* 1a. Initialize TSRM — must happen before any zend_try + * because zend_try uses EG(bailout) which requires TSRM. */ + async_thread_tsrm_init(); + + /* 1b. Start PHP request (can bailout). + * zend_first_try initializes EG(bailout) for this thread. */ + zend_first_try { + if (UNEXPECTED(async_thread_request_startup(snapshot) == FAILURE)) { + goto notify; + } + request_started = true; + } zend_catch { + thread_capture_bailout(context, &fallback_message); + goto notify; + } zend_end_try(); + + zval retval; + ZVAL_UNDEF(&retval); + + /* 2. Execute entry point */ + zend_first_try { + if (context->internal_entry != NULL) { + /* C-level entry: copy handler/ctx, free entry struct, call handler */ + void (*handler)(zend_async_thread_event_t *, void *) = context->internal_entry->handler; + void *ctx = context->internal_entry->ctx; + pefree(context->internal_entry, 1); + context->internal_entry = NULL; + + handler(event, ctx); + goto cleanup; + } + + /* Bootloader (optional) */ + if (snapshot->bootloader.func != NULL) { + if (!thread_call_closure(&snapshot->bootloader, event, &retval)) { + goto cleanup; + } + } + + /* Entry closure */ + if (thread_call_closure(&snapshot->entry, event, &retval)) { + if (!Z_ISUNDEF(retval)) { + async_thread_transfer_zval(&event->result, &retval); + + /* Transfer itself may fail (unsupported types, depth limit) */ + if (UNEXPECTED(EG(exception))) { + zval exception_zval, transferred_exception; + ZVAL_OBJ_COPY(&exception_zval, EG(exception)); + zend_clear_exception(); + async_thread_transfer_zval(&transferred_exception, &exception_zval); + event->exception = Z_OBJ(transferred_exception); + zval_ptr_dtor(&exception_zval); + ZVAL_UNDEF(&event->result); + } + } + } + +cleanup: + zval_ptr_dtor(&retval); + } zend_catch { + thread_capture_bailout(context, &fallback_message); + } zend_end_try(); + + /* 3. Shut down PHP request — must happen BEFORE snapshot destroy + * because request shutdown may destroy closures (e.g. autoloader + * callbacks) whose op_arrays reference arena-allocated strings. */ + if (EXPECTED(request_started)) { + zend_first_try { + async_thread_request_shutdown(); + } zend_catch { + /* Bailout during shutdown — not much we can do, + * but don't overwrite a prior bailout message. */ + if (context->bailout_error_message == NULL) { + thread_capture_bailout(context, &fallback_message); + } + } zend_end_try(); + } + + /* 4. Free snapshot arena — safe now that all PHP objects referencing + * arena-allocated op_array data have been destroyed by request shutdown. */ + if (context->snapshot != NULL) { + async_thread_snapshot_destroy(context->snapshot); + context->snapshot = NULL; + } + + /* Snapshot handle before releasing the context — for a lightweight + * pool worker the context is freed by the release below, so we must + * read the handle now if we want to self-remove from the registry. */ + const zend_async_thread_handle_t my_handle = context->handle; + + ZEND_ASYNC_THREAD_CONTEXT_RELEASE(context); + + /* Free TSRM storage after all zend_end_try blocks. + * Must be separate because zend_end_try accesses EG(bailout). */ +#ifdef ZTS + ts_free_thread(); +#endif + + /* Self-remove from the reactor's child thread registry. + * Must happen after ts_free_thread so the main thread, once it wakes + * in libuv_reactor_quiesce, can safely proceed into php_module_shutdown + * without racing this child against ts_free_id(). */ + if (my_handle != 0) { + async_libuv_thread_registry_remove(my_handle); + } + +notify: + if (fallback_message != NULL) { + pefree(fallback_message, 1); + } + + if (event) { + event->notify_parent(event); + } +} + +/////////////////////////////////////////////////////////// +/// 3. Thread PHP object — Async\Thread class +/////////////////////////////////////////////////////////// + +#define METHOD(name) PHP_METHOD(Async_Thread, name) +#define THIS_THREAD() Z_ASYNC_THREAD_P(ZEND_THIS) + +zend_class_entry *async_ce_thread = NULL; +zend_class_entry *async_ce_remote_exception = NULL; +zend_class_entry *async_ce_thread_transfer_exception = NULL; + +static zend_object_handlers thread_object_handlers; + +/* ---- RemoteException methods ---- */ + +PHP_METHOD(Async_RemoteException, getRemoteException) +{ + ZEND_PARSE_PARAMETERS_NONE(); + zval *prop = zend_read_property(async_ce_remote_exception, Z_OBJ_P(ZEND_THIS), + "remoteException", sizeof("remoteException") - 1, 0, NULL); + RETURN_ZVAL(prop, 1, 0); +} + +PHP_METHOD(Async_RemoteException, getRemoteClass) +{ + ZEND_PARSE_PARAMETERS_NONE(); + zval *prop = zend_read_property(async_ce_remote_exception, Z_OBJ_P(ZEND_THIS), + "remoteClass", sizeof("remoteClass") - 1, 0, NULL); + RETURN_ZVAL(prop, 1, 0); +} + +/* ---- Object Lifecycle ---- */ + +static zend_object *thread_object_create(zend_class_entry *class_entry) +{ + async_thread_object_t *thread = zend_object_alloc(sizeof(async_thread_object_t), class_entry); + + ZEND_ASYNC_EVENT_REF_SET(thread, XtOffsetOf(async_thread_object_t, std), NULL); + + thread->thread_event = NULL; + thread->parent_scope = NULL; + thread->finally_handlers = NULL; + + zend_object_std_init(&thread->std, class_entry); + object_properties_init(&thread->std, class_entry); + + return &thread->std; +} + +/* Release the extra Thread ref bumped before dispatching finally handlers. + * Invoked by finally_handlers_iterator_dtor() once all handlers finish. */ +static void thread_finally_handlers_dtor(finally_handlers_context_t *context) +{ + if (context->target != NULL) { + async_thread_object_t *thread = (async_thread_object_t *) context->target; + context->target = NULL; + OBJ_RELEASE(&thread->std); + } +} + +static void thread_object_dtor(zend_object *object) +{ + async_thread_object_t *thread = async_thread_object_from_obj(object); + + if (thread->thread_event != NULL) { + zend_async_event_t *event = &thread->thread_event->base; + + /* Call finally handlers if the thread completed. The parent scope + * was captured at spawn time and is kept alive by a refcount held + * on thread->parent_scope (released in thread_object_free), so + * async_call_finally_handlers() always has a valid parent to spawn + * the handler-dispatch coroutine under. If the async subsystem is + * already off (late zend_call_destructors path), it is not safe to + * spawn anything — drop the handlers instead. */ + if (thread->finally_handlers != NULL + && zend_hash_num_elements(thread->finally_handlers) > 0 + && ZEND_ASYNC_EVENT_IS_CLOSED(event)) { + + if (thread->parent_scope == NULL || ZEND_ASYNC_IS_OFF) { + zend_array_destroy(thread->finally_handlers); + thread->finally_handlers = NULL; + } else { + finally_handlers_context_t *finally_context = ecalloc(1, sizeof(finally_handlers_context_t)); + finally_context->target = thread; + finally_context->scope = thread->parent_scope; + finally_context->dtor = thread_finally_handlers_dtor; + finally_context->params_count = 1; + ZVAL_OBJ(&finally_context->params[0], &thread->std); + + HashTable *handlers = thread->finally_handlers; + thread->finally_handlers = NULL; + + if (async_call_finally_handlers(handlers, finally_context, 0)) { + GC_ADDREF(&thread->std); + } else { + efree(finally_context); + zend_array_destroy(handlers); + } + } + } + + /* Dispose the underlying event (Thread object is the sole owner) */ + if (event->dispose != NULL) { + event->dispose(event); + } + thread->thread_event = NULL; + } + + if (thread->finally_handlers) { + zend_array_destroy(thread->finally_handlers); + thread->finally_handlers = NULL; + } +} + +static void thread_object_free(zend_object *object) +{ + async_thread_object_t *thread = async_thread_object_from_obj(object); + + /* Release the scope ref captured at spawn time. This runs after the + * dtor has finished (and after any delayed finally-handlers kept the + * Thread alive via GC_ADDREF), so the scope outlives every handler + * that might reference it. */ + if (thread->parent_scope != NULL) { + ZEND_ASYNC_EVENT_RELEASE(&thread->parent_scope->event); + thread->parent_scope = NULL; + } + + zend_object_std_dtor(object); +} + +static HashTable *thread_object_gc(zend_object *object, zval **table, int *num) +{ + async_thread_object_t *thread = async_thread_object_from_obj(object); + + zend_get_gc_buffer *buf = zend_get_gc_buffer_create(); + + if (thread->thread_event != NULL) { + if (!Z_ISUNDEF(thread->thread_event->result)) { + zend_get_gc_buffer_add_zval(buf, &thread->thread_event->result); + } + if (thread->thread_event->exception != NULL) { + zend_get_gc_buffer_add_obj(buf, thread->thread_event->exception); + } + } + + if (thread->finally_handlers) { + zval *val; + ZEND_HASH_FOREACH_VAL(thread->finally_handlers, val) + { + zend_get_gc_buffer_add_zval(buf, val); + } + ZEND_HASH_FOREACH_END(); + } + + zend_get_gc_buffer_use(buf, table, num); + + return NULL; +} + +/* ---- PHP Methods ---- */ + +METHOD(__construct) +{ + zend_throw_error(NULL, "Cannot directly construct Async\\Thread"); +} + +METHOD(isRunning) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + const async_thread_object_t *thread = THIS_THREAD(); + + if (UNEXPECTED(thread->thread_event == NULL)) { + RETURN_FALSE; + } + + const zend_async_event_t *event = &thread->thread_event->base; + + RETURN_BOOL(event->loop_ref_count > 0 + && !ZEND_ASYNC_EVENT_IS_CLOSED(event)); +} + +METHOD(isCompleted) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + const async_thread_object_t *thread = THIS_THREAD(); + + if (UNEXPECTED(thread->thread_event == NULL)) { + RETURN_TRUE; + } + + RETURN_BOOL(ZEND_ASYNC_EVENT_IS_CLOSED(&thread->thread_event->base)); +} + +METHOD(isCancelled) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + const async_thread_object_t *thread = THIS_THREAD(); + + if (UNEXPECTED(thread->thread_event == NULL)) { + RETURN_FALSE; + } + + /* TODO: thread cancellation not yet implemented */ + RETURN_FALSE; +} + +METHOD(getResult) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + const async_thread_object_t *thread = THIS_THREAD(); + + if (UNEXPECTED(thread->thread_event == NULL) + || !ZEND_ASYNC_EVENT_IS_CLOSED(&thread->thread_event->base)) { + RETURN_NULL(); + } + + if (!Z_ISUNDEF(thread->thread_event->result)) { + RETURN_COPY(&thread->thread_event->result); + } + + RETURN_NULL(); +} + +METHOD(getException) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + const async_thread_object_t *thread = THIS_THREAD(); + + if (UNEXPECTED(thread->thread_event == NULL) + || !ZEND_ASYNC_EVENT_IS_CLOSED(&thread->thread_event->base)) { + RETURN_NULL(); + } + + if (thread->thread_event->exception != NULL) { + RETURN_OBJ_COPY(thread->thread_event->exception); + } + + RETURN_NULL(); +} + +METHOD(cancel) +{ + zval *cancellation = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_OBJECT_OF_CLASS_OR_NULL(cancellation, async_ce_completable) + ZEND_PARSE_PARAMETERS_END(); + + async_thread_object_t *thread = THIS_THREAD(); + + if (UNEXPECTED(thread->thread_event == NULL)) { + return; + } + + zend_async_event_t *event = &thread->thread_event->base; + + if (ZEND_ASYNC_EVENT_IS_CLOSED(event)) { + return; + } + + /* TODO: implement thread cancellation mechanism */ + async_throw_error("Thread cancellation is not yet implemented"); +} + +METHOD(finally) +{ + zval *callable; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ZVAL(callable) + ZEND_PARSE_PARAMETERS_END(); + + if (UNEXPECTED(!zend_is_callable(callable, 0, NULL))) { + async_throw_error("Argument #1 ($callback) must be callable"); + RETURN_THROWS(); + } + + async_thread_object_t *thread = THIS_THREAD(); + + /* If the thread is already completed, call the callback immediately */ + if (thread->thread_event != NULL + && ZEND_ASYNC_EVENT_IS_CLOSED(&thread->thread_event->base)) { + zval rv; + ZVAL_UNDEF(&rv); + + zval param; + ZVAL_OBJ(¶m, &thread->std); + + zend_fcall_info fci; + zend_fcall_info_cache fcc; + if (zend_fcall_info_init(callable, 0, &fci, &fcc, NULL, NULL) == SUCCESS) { + fci.retval = &rv; + fci.param_count = 1; + fci.params = ¶m; + zend_call_function(&fci, &fcc); + } + + zval_ptr_dtor(&rv); + return; + } + + if (thread->finally_handlers == NULL) { + thread->finally_handlers = zend_new_array(0); + } + + if (UNEXPECTED(zend_hash_next_index_insert(thread->finally_handlers, callable) == NULL)) { + async_throw_error("Failed to add finally handler to thread"); + RETURN_THROWS(); + } + + Z_TRY_ADDREF_P(callable); +} + +/* API wrappers for opaque void* signatures */ +/* API-compatible wrappers with opaque void* signatures */ +void *async_thread_snapshot_create_api( + const zend_fcall_t *entry, const zend_fcall_t *bootloader) +{ + return async_thread_snapshot_create(entry, bootloader); +} + +void async_thread_snapshot_destroy_api(void *snapshot) +{ + async_thread_snapshot_destroy((async_thread_snapshot_t *) snapshot); +} + +/////////////////////////////////////////////////////////// +/// Closure transfer_obj handler +/////////////////////////////////////////////////////////// + +/** + * Persistent wrapper for a transferred closure. + * Layout: [class_name_ptr | snapshot_ptr | zend_object shell] + * class_name stored in ce field (same convention as default transfer). + */ +static zend_object *closure_transfer_obj( + zend_object *object, zend_async_thread_transfer_ctx_t *ctx, + zend_object_transfer_kind_t kind, zend_object_transfer_default_fn default_fn) +{ + if (kind == ZEND_OBJECT_TRANSFER) { + /* Source thread → persistent: deep-copy closure via snapshot */ + const zend_function *func = zend_get_closure_method_def(object); + + zend_fcall_t fcall; + memset(&fcall, 0, sizeof(fcall)); + fcall.fci_cache.function_handler = (zend_function *) func; + + async_thread_snapshot_t *snapshot = async_thread_snapshot_create(&fcall, NULL); + if (snapshot == NULL) { + return NULL; + } + + /* Minimal persistent shell: zend_object + 1 property slot for snapshot ptr */ + size_t alloc_size = sizeof(zend_object) + sizeof(zval); + zend_object *dst = pecalloc(1, alloc_size, 1); + GC_SET_REFCOUNT(dst, 1); + dst->extra_flags = 0; /* offset = 0 */ + + /* Store class name for LOAD phase lookup */ + dst->ce = (zend_class_entry *) thread_transfer_string(ctx, object->ce->name); + dst->handlers = (const zend_object_handlers *)(uintptr_t) 0; /* prop_count = 0 */ + dst->properties = NULL; + + /* Store snapshot pointer in first property slot */ + ZVAL_LONG(&dst->properties_table[0], (zend_long)(uintptr_t) snapshot); + + return dst; + } else { + /* Destination thread → emalloc: recreate closure from snapshot */ + async_thread_snapshot_t *snapshot = + (async_thread_snapshot_t *)(uintptr_t) Z_LVAL(object->properties_table[0]); + + zval closure_zv; + async_thread_create_closure(&snapshot->entry, &closure_zv); + + /* 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); + op_array_to_emalloc(&closure->func.op_array); + + async_thread_snapshot_destroy(snapshot); + + return Z_OBJ(closure_zv); + } +} + +void async_register_thread_ce(void) +{ + async_ce_remote_exception = register_class_Async_RemoteException(async_ce_async_exception); + async_ce_thread_transfer_exception = register_class_Async_ThreadTransferException(async_ce_async_exception); + + async_ce_thread = register_class_Async_Thread(async_ce_completable); + async_ce_thread->create_object = thread_object_create; + async_ce_thread->default_object_handlers = &thread_object_handlers; + + thread_object_handlers = std_object_handlers; + thread_object_handlers.offset = XtOffsetOf(async_thread_object_t, std); + thread_object_handlers.clone_obj = NULL; + thread_object_handlers.dtor_obj = thread_object_dtor; + thread_object_handlers.free_obj = thread_object_free; + thread_object_handlers.get_gc = thread_object_gc; + + /* Register transfer_obj for Closure — enables cross-thread closure transfer */ + ((zend_object_handlers *) zend_ce_closure->default_object_handlers)->transfer_obj = closure_transfer_obj; +} diff --git a/thread.h b/thread.h new file mode 100644 index 00000000..b4b6df52 --- /dev/null +++ b/thread.h @@ -0,0 +1,220 @@ +/* ++----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to version 3.01 of the PHP license, | + | that is bundled with this package in the file LICENSE, and is | + | available through the world-wide-web at the following url: | + | https://www.php.net/license/3_01.txt | + | If you did not receive a copy of the PHP license and are unable to | + | obtain it through the world-wide-web, please send a note to | + | license@php.net so we can mail you a copy immediately. | + +----------------------------------------------------------------------+ + | Author: Edmond | + +----------------------------------------------------------------------+ +*/ +#ifndef ASYNC_THREAD_H +#define ASYNC_THREAD_H + +#include "php_async_api.h" +#include + +/////////////////////////////////////////////////////////// +/// Snapshot — package transferred from parent to child +/////////////////////////////////////////////////////////// + +/** + * Arena block for bump allocator — all op_array data lives here. + * Freed as a whole on snapshot destroy (no individual pefree needed). + */ +typedef struct _thread_copy_arena_block_t { + struct _thread_copy_arena_block_t *prev; + size_t size; + size_t offset; + char data[]; /* flexible array member */ +} thread_copy_arena_block_t; + +/** + * A deep-copied closure: arena-allocated op_array + transferred bound variables. + */ +typedef struct _async_thread_closure_copy_t { + zend_op_array *func; + HashTable *bound_vars; /* NULL if no captured variables (pemalloc, not arena) */ +} async_thread_closure_copy_t; + +typedef struct _async_thread_snapshot_t { + /* Deep-copied entry closure */ + async_thread_closure_copy_t entry; + /* Deep-copied bootloader closure (func == NULL if not provided) */ + async_thread_closure_copy_t bootloader; + /* Arena block list head — all pemalloc'd op_array data */ + thread_copy_arena_block_t *arena_blocks; +} async_thread_snapshot_t; + +/** + * Create a snapshot: deep-copy entry closure + optional bootloader, + * capture parent SAPI context. + * + * @param entry The callable to execute in the child thread + * @param bootloader Optional bootloader callable (NULL if not provided) + * @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); + +/** + * Free a snapshot and all its resources. + */ +void async_thread_snapshot_destroy(async_thread_snapshot_t *snapshot); + +/////////////////////////////////////////////////////////// +/// Thread lifecycle — PHP request in child thread +/////////////////////////////////////////////////////////// + +/** + * Initialize TSRM for the child thread. + * Must be called BEFORE any zend_try block (EG(bailout) is not available yet). + * After this call, EG()/SG()/PG() macros work correctly. + */ +void async_thread_tsrm_init(void); + +/** + * Initialize PHP request in child thread. + * Must be called after async_thread_tsrm_init(). + * + * Performs: php_request_startup, post-startup fixups. + * + * @return SUCCESS or FAILURE + */ +int async_thread_request_startup(const async_thread_snapshot_t *snapshot); + +/** + * Shut down PHP request in child thread. + * Performs php_request_shutdown only. + * Caller must call ts_free_thread() separately after zend_end_try. + */ +void async_thread_request_shutdown(void); + +/** + * Create a callable Closure from a deep-copied closure. + * Loads bound variables into current thread's emalloc. + * + * @param copy The deep-copied closure (from snapshot) + * @param closure_zv Output: the created Closure zval + */ +void async_thread_create_closure( + const async_thread_closure_copy_t *copy, zval *closure_zv); + +/** + * Thread entry point: run the snapshot's closures in a new PHP request. + * + * Handles the full lifecycle: request startup, bootloader, entry closure, + * result/exception transfer, request shutdown, and parent notification. + * Called from the reactor's OS thread callback. + * + * @param arg Pointer to zend_async_thread_event_t (cast from void* for + * compatibility with OS thread entry signatures) + */ +void async_thread_run(void *arg); + +/** + * Load thread result/exception from persistent memory into parent's emalloc. + * Sets ZEND_THREAD_F_RESULT_LOADED flag. + */ +void async_thread_load_result(zend_async_thread_event_t *event); + +/* API-compatible wrappers (opaque void* signatures for function pointers) */ +void *async_thread_snapshot_create_api( + const zend_fcall_t *entry, const zend_fcall_t *bootloader); +void async_thread_snapshot_destroy_api(void *snapshot); + +/////////////////////////////////////////////////////////// +/// Zval transfer — copy runtime values between threads +/////////////////////////////////////////////////////////// + +/* thread_transfer_ctx_t is an alias for the Zend-level type */ +typedef zend_async_thread_transfer_ctx_t thread_transfer_ctx_t; + +/** + * Copy a zval into persistent memory for cross-thread transfer. + * Deep copies strings, arrays, and objects. Preserves identity + * (shared references → shared copies) and handles cycles. + */ +void async_thread_transfer_zval(zval *dst, const zval *src); + +/** + * Load a persistent zval into the current thread's emalloc heap. + * Creates proper refcounted copies owned by the calling thread. + */ +void async_thread_load_zval(zval *dst, const zval *src); + +/** + * Free a persistent zval created by async_thread_transfer_zval(). + */ +void async_thread_release_transferred_zval(zval *z); + +/* Recursion helpers passed to zend_async_thread_pool_register() — expose + * thread_transfer_zval_inner / thread_load_zval_inner / xlat_put / a lazy + * defer-release list. Used by transfer_obj handlers in Zend core classes + * (e.g. WeakReference, WeakMap) that need to deep-copy child zvals within + * an existing ctx, share the xlat table with their caller, and defer the + * release of locally-created temporaries until the load ctx is torn down. */ +void async_thread_transfer_zval_ctx( + zend_async_thread_transfer_ctx_t *ctx, zval *dst, const zval *src); +void async_thread_load_zval_ctx( + zend_async_thread_transfer_ctx_t *ctx, zval *dst, const zval *src); +void async_thread_xlat_put_ctx( + zend_async_thread_transfer_ctx_t *ctx, const void *src, void *dst); +void async_thread_defer_release_ctx( + zend_async_thread_transfer_ctx_t *ctx, zval *z); + +/////////////////////////////////////////////////////////// +/// Thread exceptions +/////////////////////////////////////////////////////////// + +PHP_ASYNC_API extern zend_class_entry *async_ce_remote_exception; +PHP_ASYNC_API extern zend_class_entry *async_ce_thread_transfer_exception; + +/////////////////////////////////////////////////////////// +/// Thread PHP object — Async\Thread class +/////////////////////////////////////////////////////////// + +typedef struct _async_thread_object_s async_thread_object_t; + +PHP_ASYNC_API extern zend_class_entry *async_ce_thread; + +struct _async_thread_object_s +{ + /* Event reference — allows ZEND_ASYNC_OBJECT_TO_EVENT() to + * resolve from this object to the thread event pointer. + * Must be at offset 0 from the struct start (= handlers->offset from zend_object). */ + ZEND_ASYNC_EVENT_REF_FIELDS + + /* Pointer to the underlying thread event */ + zend_async_thread_event_t *thread_event; + + /* Scope active at the moment spawn_thread() was called. Used as the + * parent for finally-handler dispatch so the handlers inherit the + * caller's async context instead of being detached in the main scope. + * Held via ZEND_ASYNC_EVENT_ADD_REF on the scope event and released + * in thread_object_free(). May be NULL if spawn happened outside of + * any scope (defensive). */ + zend_async_scope_t *parent_scope; + + /* Finally handlers array (zval callables) - lazy initialization */ + HashTable *finally_handlers; + + /* PHP object handle — must be last */ + zend_object std; +}; + +void async_register_thread_ce(void); + +static zend_always_inline async_thread_object_t *async_thread_object_from_obj(zend_object *obj) +{ + return (async_thread_object_t *) ((char *) obj - XtOffsetOf(async_thread_object_t, std)); +} + +#define Z_ASYNC_THREAD_P(zv) async_thread_object_from_obj(Z_OBJ_P(zv)) + +#endif /* ASYNC_THREAD_H */ diff --git a/thread.stub.php b/thread.stub.php new file mode 100644 index 00000000..aaf8ae30 --- /dev/null +++ b/thread.stub.php @@ -0,0 +1,62 @@ +channel) + +#define ENSURE_COROUTINE_CONTEXT \ + if (UNEXPECTED(ZEND_ASYNC_CURRENT_COROUTINE == NULL)) { \ + async_scheduler_launch(); \ + if (UNEXPECTED(EG(exception) != NULL)) { \ + RETURN_THROWS(); \ + } \ + } + +zend_class_entry *async_ce_thread_channel = NULL; +zend_class_entry *async_ce_thread_channel_exception = NULL; +static zend_object_handlers async_thread_channel_handlers; + +/////////////////////////////////////////////////////////////////////////////// +// Trigger helpers +/////////////////////////////////////////////////////////////////////////////// + +/* Ensure wrapper has a trigger event (lazy init). */ +static zend_async_trigger_event_t *ensure_wrapper_trigger(thread_channel_object_t *obj) +{ + if (obj->event == NULL) { + zend_async_trigger_event_t *trigger = ZEND_ASYNC_NEW_TRIGGER_EVENT(); + ZEND_ASYNC_EVENT_REF_SET(obj, XtOffsetOf(thread_channel_object_t, std), &trigger->base); + } + return ASYNC_THREAD_CHANNEL_TRIGGER(obj); +} + +/* Register wrapper's trigger in a channel trigger set. Mutex must be held. */ +static void register_trigger(HashTable *triggers, thread_channel_object_t *obj) +{ + zend_hash_index_update_ptr(triggers, (zend_ulong)(uintptr_t) obj, ASYNC_THREAD_CHANNEL_TRIGGER(obj)); +} + +/* Unregister wrapper's trigger from a channel trigger set. Mutex must be held. */ +static void unregister_trigger(HashTable *triggers, thread_channel_object_t *obj) +{ + zend_hash_index_del(triggers, (zend_ulong)(uintptr_t) obj); +} + +/* Fire all trigger events in the given mapping to wake up waiting threads. */ +static void fire_all_triggers(HashTable *triggers) +{ + zend_async_trigger_event_t *trigger; + ZEND_HASH_FOREACH_PTR(triggers, trigger) { + trigger->trigger(trigger); + } ZEND_HASH_FOREACH_END(); +} + +/////////////////////////////////////////////////////////////////////////////// +// C-level send/receive (coroutine-aware) +/////////////////////////////////////////////////////////////////////////////// + +static bool thread_channel_send(zend_async_channel_t *channel, zval *value); +static bool thread_channel_receive(zend_async_channel_t *channel, zval *result); + +static bool thread_channel_send(zend_async_channel_t *channel, zval *value) +{ + async_thread_channel_t *ch = (async_thread_channel_t *) channel; + zend_async_trigger_event_t *trigger = NULL; + + /* Transfer value to persistent memory (once, reused across retries) */ + zval persistent_copy; + async_thread_transfer_zval(&persistent_copy, value); + +retry: + ASYNC_MUTEX_LOCK(ch->mutex); + + /* Check closed under lock */ + if (UNEXPECTED(ZEND_ASYNC_EVENT_IS_CLOSED(&ch->channel.event))) { + ASYNC_MUTEX_UNLOCK(ch->mutex); + async_thread_release_transferred_zval(&persistent_copy); + if (trigger != NULL) { + trigger->base.dispose(&trigger->base); + } + zend_throw_exception(async_ce_thread_channel_exception, "ThreadChannel is closed", 0); + return false; + } + + if (circular_buffer_count(&ch->buffer) < (size_t) ch->capacity) { + /* Buffer has space — push and notify waiting receivers */ + circular_buffer_push(&ch->buffer, &persistent_copy, false); + fire_all_triggers(&ch->receiver_triggers); + ASYNC_MUTEX_UNLOCK(ch->mutex); + if (trigger != NULL) { + trigger->base.dispose(&trigger->base); + } + return true; + } + + /* Buffer is full — create trigger (once), register and suspend */ + if (trigger == NULL) { + trigger = ZEND_ASYNC_NEW_TRIGGER_EVENT(); + } + zend_hash_index_update_ptr(&ch->sender_triggers, (zend_ulong)(uintptr_t) trigger, trigger); + ASYNC_MUTEX_UNLOCK(ch->mutex); + + zend_async_resume_when(ZEND_ASYNC_CURRENT_COROUTINE, + &trigger->base, false, zend_async_waker_callback_resolve, NULL); + ZEND_ASYNC_SUSPEND(); + ZEND_ASYNC_WAKER_DESTROY(ZEND_ASYNC_CURRENT_COROUTINE); + + /* Woke up — remove from sender queue */ + ASYNC_MUTEX_LOCK(ch->mutex); + zend_hash_index_del(&ch->sender_triggers, (zend_ulong)(uintptr_t) trigger); + ASYNC_MUTEX_UNLOCK(ch->mutex); + + if (EG(exception)) { + async_thread_release_transferred_zval(&persistent_copy); + trigger->base.dispose(&trigger->base); + return false; + } + + goto retry; +} + +static bool thread_channel_receive(zend_async_channel_t *channel, zval *result) +{ + async_thread_channel_t *ch = (async_thread_channel_t *) channel; + zend_async_trigger_event_t *trigger = NULL; + +retry: + ASYNC_MUTEX_LOCK(ch->mutex); + + if (circular_buffer_is_not_empty(&ch->buffer)) { + /* Data available — pop and notify waiting senders */ + zval persistent_zval; + circular_buffer_pop(&ch->buffer, &persistent_zval); + fire_all_triggers(&ch->sender_triggers); + ASYNC_MUTEX_UNLOCK(ch->mutex); + + async_thread_load_zval(result, &persistent_zval); + async_thread_release_transferred_zval(&persistent_zval); + + if (trigger != NULL) { + trigger->base.dispose(&trigger->base); + } + return true; + } + + /* Buffer empty — check if closed */ + if (UNEXPECTED(ZEND_ASYNC_EVENT_IS_CLOSED(&ch->channel.event))) { + ASYNC_MUTEX_UNLOCK(ch->mutex); + if (trigger != NULL) { + trigger->base.dispose(&trigger->base); + } + zend_throw_exception(async_ce_thread_channel_exception, "ThreadChannel is closed", 0); + return false; + } + + /* Buffer empty, not closed — create trigger (once), register and suspend */ + if (trigger == NULL) { + trigger = ZEND_ASYNC_NEW_TRIGGER_EVENT(); + } + zend_hash_index_update_ptr(&ch->receiver_triggers, (zend_ulong)(uintptr_t) trigger, trigger); + ASYNC_MUTEX_UNLOCK(ch->mutex); + + zend_async_resume_when(ZEND_ASYNC_CURRENT_COROUTINE, + &trigger->base, false, zend_async_waker_callback_resolve, NULL); + ZEND_ASYNC_SUSPEND(); + ZEND_ASYNC_WAKER_DESTROY(ZEND_ASYNC_CURRENT_COROUTINE); + + /* Woke up — remove from receiver queue */ + ASYNC_MUTEX_LOCK(ch->mutex); + zend_hash_index_del(&ch->receiver_triggers, (zend_ulong)(uintptr_t) trigger); + ASYNC_MUTEX_UNLOCK(ch->mutex); + + if (EG(exception)) { + trigger->base.dispose(&trigger->base); + return false; + } + + goto retry; +} + +void async_thread_channel_close(async_thread_channel_t *ch) +{ + ASYNC_MUTEX_LOCK(ch->mutex); + + if (ZEND_ASYNC_EVENT_IS_CLOSED(&ch->channel.event)) { + ASYNC_MUTEX_UNLOCK(ch->mutex); + return; + } + + ZEND_ASYNC_EVENT_SET_CLOSED(&ch->channel.event); + fire_all_triggers(&ch->receiver_triggers); + fire_all_triggers(&ch->sender_triggers); + + ASYNC_MUTEX_UNLOCK(ch->mutex); +} + +static void thread_channel_close(zend_async_channel_t *channel) +{ + async_thread_channel_close((async_thread_channel_t *) channel); +} + +/////////////////////////////////////////////////////////////////////////////// +// Thread channel allocation / destruction +/////////////////////////////////////////////////////////////////////////////// + +static bool thread_channel_event_dispose(zend_async_event_t *event); + +async_thread_channel_t *async_thread_channel_create(int32_t capacity) +{ + async_thread_channel_t *ch = pecalloc(1, sizeof(async_thread_channel_t), 1); + + ch->capacity = capacity; + zend_atomic_int_store(&ch->ref_count, 1); + + ASYNC_MUTEX_INIT(ch->mutex); + + /* +1 for sentinel slot in circular buffer */ + circular_buffer_ctor(&ch->buffer, capacity + 1, sizeof(zval), &zend_std_persistent_allocator); + + /* Triggers are owned by callers, not by channel — no dtor */ + zend_hash_init(&ch->receiver_triggers, 0, NULL, NULL, 1); + zend_hash_init(&ch->sender_triggers, 0, NULL, NULL, 1); + + ch->channel.send = thread_channel_send; + ch->channel.receive = thread_channel_receive; + ch->channel.close = thread_channel_close; + ch->channel.event.dispose = thread_channel_event_dispose; + + return ch; +} + +static void thread_channel_destroy(async_thread_channel_t *ch) +{ + /* Close and notify waiters before destroying */ + if (!ZEND_ASYNC_EVENT_IS_CLOSED(&ch->channel.event)) { + ASYNC_MUTEX_LOCK(ch->mutex); + ZEND_ASYNC_EVENT_SET_CLOSED(&ch->channel.event); + fire_all_triggers(&ch->receiver_triggers); + fire_all_triggers(&ch->sender_triggers); + ASYNC_MUTEX_UNLOCK(ch->mutex); + } + + /* Drain buffer — release all transferred zvals */ + zval tmp; + while (circular_buffer_is_not_empty(&ch->buffer) && + circular_buffer_pop(&ch->buffer, &tmp) == SUCCESS) { + async_thread_release_transferred_zval(&tmp); + } + circular_buffer_dtor(&ch->buffer); + + zend_hash_destroy(&ch->receiver_triggers); + zend_hash_destroy(&ch->sender_triggers); + + ASYNC_MUTEX_DESTROY(ch->mutex); + pefree(ch, 1); +} + +void async_thread_channel_addref(async_thread_channel_t *ch) +{ + int old; + do { + old = zend_atomic_int_load(&ch->ref_count); + } while (!zend_atomic_int_compare_exchange(&ch->ref_count, &old, old + 1)); +} + +static bool thread_channel_event_dispose(zend_async_event_t *event) +{ + /* channel.event is at offset 0, so direct cast is safe */ + async_thread_channel_t *ch = (async_thread_channel_t *) event; + + int old; + do { + old = zend_atomic_int_load(&ch->ref_count); + } while (!zend_atomic_int_compare_exchange(&ch->ref_count, &old, old - 1)); + + if (old == 1) { + thread_channel_destroy(ch); + } + + return true; +} + +/////////////////////////////////////////////////////////////////////////////// +// Object handlers +/////////////////////////////////////////////////////////////////////////////// + +static HashTable *async_thread_channel_get_gc(zend_object *object, zval **table, int *num) +{ + *table = NULL; + *num = 0; + return NULL; +} + +static zend_object *async_thread_channel_create_object(zend_class_entry *ce) +{ + thread_channel_object_t *obj = zend_object_alloc(sizeof(thread_channel_object_t), ce); + + zend_object_std_init(&obj->std, ce); + obj->std.handlers = &async_thread_channel_handlers; + obj->channel = NULL; + obj->event = NULL; + + return &obj->std; +} + +static void async_thread_channel_dtor_object(zend_object *object) +{ + thread_channel_object_t *obj = ASYNC_THREAD_CHANNEL_FROM_OBJ(object); + + /* Unregister our trigger from channel's trigger sets */ + if (obj->channel != NULL) { + ASYNC_MUTEX_LOCK(obj->channel->mutex); + unregister_trigger(&obj->channel->receiver_triggers, obj); + unregister_trigger(&obj->channel->sender_triggers, obj); + ASYNC_MUTEX_UNLOCK(obj->channel->mutex); + } + + zend_object_std_dtor(object); +} + +static zend_object *async_thread_channel_transfer_obj( + zend_object *object, zend_async_thread_transfer_ctx_t *ctx, + zend_object_transfer_kind_t kind, zend_object_transfer_default_fn default_fn) +{ + if (kind == ZEND_OBJECT_TRANSFER) { + /* Transfer: pemalloc wrapper via default, then copy channel pointer */ + zend_object *dst = default_fn(object, ctx, sizeof(thread_channel_object_t)); + + thread_channel_object_t *src_obj = ASYNC_THREAD_CHANNEL_FROM_OBJ(object); + thread_channel_object_t *dst_obj = ASYNC_THREAD_CHANNEL_FROM_OBJ(dst); + + async_thread_channel_addref(src_obj->channel); + dst_obj->channel = src_obj->channel; + + return dst; + } else { + /* Load: create emalloc object via default, then restore channel pointer */ + zend_object *dst = default_fn(object, ctx, 0); + + thread_channel_object_t *src_obj = ASYNC_THREAD_CHANNEL_FROM_OBJ(object); + thread_channel_object_t *dst_obj = ASYNC_THREAD_CHANNEL_FROM_OBJ(dst); + + dst_obj->channel = src_obj->channel; + + return dst; + } +} + +static void async_thread_channel_free_object(zend_object *object) +{ + thread_channel_object_t *obj = ASYNC_THREAD_CHANNEL_FROM_OBJ(object); + + if (obj->event != NULL) { + obj->event->dispose(obj->event); + obj->event = NULL; + } + + if (obj->channel != NULL) { + obj->channel->channel.event.dispose(&obj->channel->channel.event); + obj->channel = NULL; + } +} + +/////////////////////////////////////////////////////////////////////////////// +// PHP Methods +/////////////////////////////////////////////////////////////////////////////// + +METHOD(__construct) +{ + zend_long capacity = 16; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_LONG(capacity) + ZEND_PARSE_PARAMETERS_END(); + + if (capacity < 1) { + zend_argument_value_error(1, "must be >= 1"); + RETURN_THROWS(); + } + + thread_channel_object_t *obj = ASYNC_THREAD_CHANNEL_FROM_OBJ(Z_OBJ_P(ZEND_THIS)); + obj->channel = async_thread_channel_create((int32_t) capacity); +} + +METHOD(send) +{ + zval *value; + zend_object *cancellation_token = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 2) + Z_PARAM_ZVAL(value) + Z_PARAM_OPTIONAL + Z_PARAM_OBJ_OF_CLASS_OR_NULL(cancellation_token, async_ce_completable) + ZEND_PARSE_PARAMETERS_END(); + + ENSURE_COROUTINE_CONTEXT + + if (!THIS_CHANNEL()->channel.send(&THIS_CHANNEL()->channel, value)) { + RETURN_THROWS(); + } +} + +METHOD(recv) +{ + zend_object *cancellation_token = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_OBJ_OF_CLASS_OR_NULL(cancellation_token, async_ce_completable) + ZEND_PARSE_PARAMETERS_END(); + + ENSURE_COROUTINE_CONTEXT + + if (!THIS_CHANNEL()->channel.receive(&THIS_CHANNEL()->channel, return_value)) { + RETURN_THROWS(); + } +} + +METHOD(close) +{ + ZEND_PARSE_PARAMETERS_NONE(); + THIS_CHANNEL()->channel.close(&THIS_CHANNEL()->channel); +} + +METHOD(isClosed) +{ + ZEND_PARSE_PARAMETERS_NONE(); + RETURN_BOOL(ZEND_ASYNC_EVENT_IS_CLOSED(&THIS_CHANNEL()->channel.event)); +} + +METHOD(capacity) +{ + ZEND_PARSE_PARAMETERS_NONE(); + RETURN_LONG(THIS_CHANNEL()->capacity); +} + +METHOD(count) +{ + ZEND_PARSE_PARAMETERS_NONE(); + async_thread_channel_t *ch = THIS_CHANNEL(); + ASYNC_MUTEX_LOCK(ch->mutex); + size_t count = circular_buffer_count(&ch->buffer); + ASYNC_MUTEX_UNLOCK(ch->mutex); + RETURN_LONG(count); +} + +METHOD(isEmpty) +{ + ZEND_PARSE_PARAMETERS_NONE(); + async_thread_channel_t *ch = THIS_CHANNEL(); + ASYNC_MUTEX_LOCK(ch->mutex); + bool empty = circular_buffer_is_empty(&ch->buffer); + ASYNC_MUTEX_UNLOCK(ch->mutex); + RETURN_BOOL(empty); +} + +METHOD(isFull) +{ + ZEND_PARSE_PARAMETERS_NONE(); + async_thread_channel_t *ch = THIS_CHANNEL(); + ASYNC_MUTEX_LOCK(ch->mutex); + bool full = circular_buffer_count(&ch->buffer) >= (size_t) ch->capacity; + ASYNC_MUTEX_UNLOCK(ch->mutex); + RETURN_BOOL(full); +} + +/////////////////////////////////////////////////////////////////////////////// +// Registration +/////////////////////////////////////////////////////////////////////////////// + +void async_register_thread_channel_ce(void) +{ + async_ce_thread_channel_exception = register_class_Async_ThreadChannelException(async_ce_async_exception); + + async_ce_thread_channel = register_class_Async_ThreadChannel(async_ce_awaitable, zend_ce_countable); + + async_ce_thread_channel->create_object = async_thread_channel_create_object; + + memcpy(&async_thread_channel_handlers, &std_object_handlers, sizeof(zend_object_handlers)); + async_ce_thread_channel->default_object_handlers = &async_thread_channel_handlers; + async_thread_channel_handlers.offset = XtOffsetOf(thread_channel_object_t, std); + async_thread_channel_handlers.get_gc = async_thread_channel_get_gc; + async_thread_channel_handlers.dtor_obj = async_thread_channel_dtor_object; + async_thread_channel_handlers.free_obj = async_thread_channel_free_object; + async_thread_channel_handlers.clone_obj = NULL; + async_thread_channel_handlers.transfer_obj = async_thread_channel_transfer_obj; +} diff --git a/thread_channel.h b/thread_channel.h new file mode 100644 index 00000000..324f90ee --- /dev/null +++ b/thread_channel.h @@ -0,0 +1,90 @@ +/* ++----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to version 3.01 of the PHP license, | + | that is bundled with this package in the file LICENSE, and is | + | available through the world-wide-web at the following url: | + | https://www.php.net/license/3_01.txt | + | If you did not receive a copy of the PHP license and are unable to | + | obtain it through the world-wide-web, please send a note to | + | license@php.net so we can mail you a copy immediately. | + +----------------------------------------------------------------------+ + | Author: Edmond | + +----------------------------------------------------------------------+ +*/ +#ifndef ASYNC_THREAD_CHANNEL_H +#define ASYNC_THREAD_CHANNEL_H + +#include "php_async_api.h" +#include +#include "internal/circular_buffer.h" + +/////////////////////////////////////////////////////////// +/// Thread-safe channel (persistent memory) +/////////////////////////////////////////////////////////// + +typedef struct _async_thread_channel_s async_thread_channel_t; + +struct _async_thread_channel_s { + /* ABI base (must be first) — provides event + send/receive pointers */ + zend_async_channel_t channel; + + /* Buffered data storage (pemalloc allocator) */ + circular_buffer_t buffer; + + /* Mutex protecting buffer and trigger mappings. + * Present only in ZTS builds — threading is ZTS-only. */ +#ifdef ZTS + MUTEX_T mutex; +#endif + + /* Channel capacity (always >= 1) */ + int32_t capacity; + + /* Per-wrapper trigger registrations. + * HashTable: wrapper_ptr (zend_ulong) → zend_async_trigger_event_t* + * Wrappers register their trigger when waiting for send/recv. + * Other threads fire all registered triggers to wake waiters. */ + HashTable receiver_triggers; /* triggers from wrappers waiting to receive */ + HashTable sender_triggers; /* triggers from wrappers waiting to send */ + + /* Reference count for cross-thread sharing */ + zend_atomic_int ref_count; +}; + +/////////////////////////////////////////////////////////// +/// PHP object wrapper (emalloc, per-thread) +/////////////////////////////////////////////////////////// + +typedef struct _thread_channel_object_s { + ZEND_ASYNC_EVENT_REF_FIELDS /* flags, zend_object_offset, *event → trigger */ + async_thread_channel_t *channel; /* pemalloc'd, shared */ + zend_object std; /* must be last */ +} thread_channel_object_t; + +/* Class entries */ +extern zend_class_entry *async_ce_thread_channel; +extern zend_class_entry *async_ce_thread_channel_exception; + +/* Convert zend_object to thread_channel_object_t */ +#define ASYNC_THREAD_CHANNEL_FROM_OBJ(obj) \ + ((thread_channel_object_t *)((char *)(obj) - XtOffsetOf(thread_channel_object_t, std))) + +/* Get trigger event from wrapper (stored via ZEND_ASYNC_EVENT_REF_FIELDS) */ +#define ASYNC_THREAD_CHANNEL_TRIGGER(obj) \ + ((zend_async_trigger_event_t *)(obj)->event) + +/* Create shared channel (C-level, no PHP wrapper) */ +async_thread_channel_t *async_thread_channel_create(int32_t capacity); + +/* Close channel — wakes all waiters, rejects new send/recv */ +void async_thread_channel_close(async_thread_channel_t *ch); + +/* Addref shared channel */ +void async_thread_channel_addref(async_thread_channel_t *ch); + +/* Registration function */ +void async_register_thread_channel_ce(void); + +#endif /* ASYNC_THREAD_CHANNEL_H */ diff --git a/thread_channel.stub.php b/thread_channel.stub.php new file mode 100644 index 00000000..7e809241 --- /dev/null +++ b/thread_channel.stub.php @@ -0,0 +1,89 @@ += 1). No rendezvous mode. + * + * @strict-properties + * @not-serializable + */ +final class ThreadChannel implements Awaitable, \Countable +{ + /** + * Create a new thread-safe channel. + * + * @param int $capacity Buffer size (must be >= 1) + */ + public function __construct(int $capacity = 16) {} + + /** + * Send a value into the channel (blocking). + * + * Suspends the current coroutine until buffer space is available. + * The value is deep-copied into persistent memory. + * + * @param Completable|null $cancellationToken Optional cancellation token + * @throws ThreadChannelException if channel is closed + */ + public function send(mixed $value, ?Completable $cancellationToken = null): void {} + + /** + * Receive a value from the channel (blocking). + * + * Suspends the current coroutine until a value is available. + * The value is copied from persistent memory into the current thread. + * + * @param Completable|null $cancellationToken Optional cancellation token + * @throws ThreadChannelException if channel is closed and empty + */ + public function recv(?Completable $cancellationToken = null): mixed {} + + /** + * Close the channel. + * + * After closing: + * - send() throws ThreadChannelException + * - recv() drains remaining values, then throws ThreadChannelException + * - All waiting coroutines are woken with ThreadChannelException + */ + public function close(): void {} + + /** + * Check whether the channel is closed. + */ + public function isClosed(): bool {} + + /** + * Get channel capacity. + */ + public function capacity(): int {} + + /** + * Current number of buffered values. + */ + public function count(): int {} + + /** + * Check if channel is empty. + */ + public function isEmpty(): bool {} + + /** + * Check if channel is full. + */ + public function isFull(): bool {} +} diff --git a/thread_channel_arginfo.h b/thread_channel_arginfo.h new file mode 100644 index 00000000..7f34dd80 --- /dev/null +++ b/thread_channel_arginfo.h @@ -0,0 +1,74 @@ +/* This is a generated file, edit thread_channel.stub.php instead. + * Stub hash: 9ce6bb2923998be923492ffcf9b40054d6a0b3fb */ + +ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Async_ThreadChannel___construct, 0, 0, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, capacity, IS_LONG, 0, "16") +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_ThreadChannel_send, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, cancellationToken, Async\\Completable, 1, "null") +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_ThreadChannel_recv, 0, 0, IS_MIXED, 0) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, cancellationToken, Async\\Completable, 1, "null") +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_ThreadChannel_close, 0, 0, IS_VOID, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_ThreadChannel_isClosed, 0, 0, _IS_BOOL, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_ThreadChannel_capacity, 0, 0, IS_LONG, 0) +ZEND_END_ARG_INFO() + +#define arginfo_class_Async_ThreadChannel_count arginfo_class_Async_ThreadChannel_capacity + +#define arginfo_class_Async_ThreadChannel_isEmpty arginfo_class_Async_ThreadChannel_isClosed + +#define arginfo_class_Async_ThreadChannel_isFull arginfo_class_Async_ThreadChannel_isClosed + +ZEND_METHOD(Async_ThreadChannel, __construct); +ZEND_METHOD(Async_ThreadChannel, send); +ZEND_METHOD(Async_ThreadChannel, recv); +ZEND_METHOD(Async_ThreadChannel, close); +ZEND_METHOD(Async_ThreadChannel, isClosed); +ZEND_METHOD(Async_ThreadChannel, capacity); +ZEND_METHOD(Async_ThreadChannel, count); +ZEND_METHOD(Async_ThreadChannel, isEmpty); +ZEND_METHOD(Async_ThreadChannel, isFull); + +static const zend_function_entry class_Async_ThreadChannel_methods[] = { + ZEND_ME(Async_ThreadChannel, __construct, arginfo_class_Async_ThreadChannel___construct, ZEND_ACC_PUBLIC) + ZEND_ME(Async_ThreadChannel, send, arginfo_class_Async_ThreadChannel_send, ZEND_ACC_PUBLIC) + ZEND_ME(Async_ThreadChannel, recv, arginfo_class_Async_ThreadChannel_recv, ZEND_ACC_PUBLIC) + ZEND_ME(Async_ThreadChannel, close, arginfo_class_Async_ThreadChannel_close, ZEND_ACC_PUBLIC) + ZEND_ME(Async_ThreadChannel, isClosed, arginfo_class_Async_ThreadChannel_isClosed, ZEND_ACC_PUBLIC) + ZEND_ME(Async_ThreadChannel, capacity, arginfo_class_Async_ThreadChannel_capacity, ZEND_ACC_PUBLIC) + ZEND_ME(Async_ThreadChannel, count, arginfo_class_Async_ThreadChannel_count, ZEND_ACC_PUBLIC) + ZEND_ME(Async_ThreadChannel, isEmpty, arginfo_class_Async_ThreadChannel_isEmpty, ZEND_ACC_PUBLIC) + ZEND_ME(Async_ThreadChannel, isFull, arginfo_class_Async_ThreadChannel_isFull, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + +static zend_class_entry *register_class_Async_ThreadChannelException(zend_class_entry *class_entry_Async_AsyncException) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Async", "ThreadChannelException", NULL); + class_entry = zend_register_internal_class_with_flags(&ce, class_entry_Async_AsyncException, 0); + + return class_entry; +} + +static zend_class_entry *register_class_Async_ThreadChannel(zend_class_entry *class_entry_Async_Awaitable, zend_class_entry *class_entry_Countable) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Async", "ThreadChannel", class_Async_ThreadChannel_methods); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); + zend_class_implements(class_entry, 2, class_entry_Async_Awaitable, class_entry_Countable); + + return class_entry; +} diff --git a/thread_pool.c b/thread_pool.c new file mode 100644 index 00000000..c5ea8cee --- /dev/null +++ b/thread_pool.c @@ -0,0 +1,720 @@ +/* ++----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to version 3.01 of the PHP license, | + | that is bundled with this package in the file LICENSE, and is | + | available through the world-wide-web at the following url: | + | https://www.php.net/license/3_01.txt | + | If you did not receive a copy of the PHP license and are unable to | + | obtain it through the world-wide-web, please send a note to | + | license@php.net so we can mail you a copy immediately. | + +----------------------------------------------------------------------+ + | Author: Edmond | + +----------------------------------------------------------------------+ +*/ + +#include "thread_pool.h" +#include "thread_pool_arginfo.h" +#include "thread.h" +#include "async_API.h" +#include "exceptions.h" +#include "php_async.h" +#include "scheduler.h" +#include "thread_channel.h" +#include "future.h" +#include "zend_interfaces.h" +#include "zend_exceptions.h" + +zend_class_entry *async_ce_thread_pool = NULL; +zend_class_entry *async_ce_thread_pool_exception = NULL; + +static zend_object_handlers thread_pool_handlers; + +#define METHOD(name) PHP_METHOD(Async_ThreadPool, name) +#define THIS_POOL() (ASYNC_THREAD_POOL_FROM_OBJ(Z_OBJ_P(ZEND_THIS))->pool) + +/////////////////////////////////////////////////////////// +/// Pool refcount +/////////////////////////////////////////////////////////// + +static void thread_pool_destroy(async_thread_pool_t *pool); + +/////////////////////////////////////////////////////////// +/// Worker entry — C handler called inside spawned thread +/////////////////////////////////////////////////////////// + +/** + * @brief Worker loop — receives tasks from channel, executes, completes. + * + * ctx = async_thread_pool_t* (shared pool). + * Each task is an array: [callable, args_array, shared_state_ptr_as_long] + * transferred through ThreadChannel automatically. + */ +static zend_function worker_root_function = { ZEND_INTERNAL_FUNCTION }; + +/* event is always NULL for pool workers (started via ZEND_ASYNC_START_THREAD) */ +static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *ctx) +{ + async_thread_pool_t *pool = (async_thread_pool_t *) ctx; + async_thread_channel_t *channel = pool->task_channel; + int bailout = 0; + + ZEND_ASSERT(event == NULL); + + /* Create a fake internal frame so EG(current_execute_data) != NULL. + * Without this, zend_throw_exception triggers bailout because it thinks + * there is no PHP stack to catch the exception. */ + zend_execute_data fake_frame = {0}; + fake_frame.func = &worker_root_function; + fake_frame.prev_execute_data = EG(current_execute_data); + EG(current_execute_data) = &fake_frame; + + zend_try { + ZEND_ASYNC_SCHEDULER_INIT(); + + if (UNEXPECTED(EG(exception))) { + zend_exception_error(EG(exception), E_WARNING); + zend_clear_exception(); + goto done; + } + + zval task; + while (channel->channel.receive(&channel->channel, &task)) { + ZEND_ASSERT(Z_TYPE(task) == IS_ARRAY); + + /* Extract: [snapshot_ptr, args_array, state_ptr] */ + async_thread_snapshot_t *snapshot = + (async_thread_snapshot_t *)(uintptr_t) Z_LVAL_P(zend_hash_index_find(Z_ARRVAL(task), 0)); + const zval *args_zv = zend_hash_index_find(Z_ARRVAL(task), 1); + zend_future_shared_state_t *state = + (zend_future_shared_state_t *)(uintptr_t) Z_LVAL_P(zend_hash_index_find(Z_ARRVAL(task), 2)); + + zval callable, retval; + zval *params = NULL; + uint32_t param_count = 0; + ZVAL_UNDEF(&retval); + ZVAL_UNDEF(&callable); + + zend_atomic_int_dec(&pool->base.pending_count); + zend_atomic_int_inc(&pool->base.running_count); + + async_thread_create_closure(&snapshot->entry, &callable); + + if (UNEXPECTED(EG(exception))) { + async_future_shared_state_reject(state, EG(exception)); + zend_clear_exception(); + goto task_cleanup; + } + + zend_fcall_info fci; + zend_fcall_info_cache fcc; + + if (UNEXPECTED(zend_fcall_info_init(&callable, 0, &fci, &fcc, NULL, NULL) != SUCCESS)) { + if (EG(exception)) { + async_future_shared_state_reject(state, EG(exception)); + zend_clear_exception(); + } + goto task_cleanup; + } + + fci.retval = &retval; + param_count = zend_hash_num_elements(Z_ARRVAL_P(args_zv)); + + if (param_count > 0) { + params = emalloc(sizeof(zval) * param_count); + fci.params = params; + fci.param_count = param_count; + uint32_t i = 0; + zval *arg; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(args_zv), arg) { + ZVAL_COPY(¶ms[i++], arg); + } ZEND_HASH_FOREACH_END(); + } + + if (zend_call_function(&fci, &fcc) == SUCCESS && !EG(exception)) { + async_future_shared_state_complete(state, &retval); + } else if (EG(exception)) { + async_future_shared_state_reject(state, EG(exception)); + zend_clear_exception(); + } + + task_cleanup: + if (params) { + for (uint32_t i = 0; i < param_count; i++) { + zval_ptr_dtor(¶ms[i]); + } + efree(params); + } + zval_ptr_dtor(&retval); + zval_ptr_dtor(&callable); + async_thread_snapshot_destroy(snapshot); + async_future_shared_state_delref(state); + zval_ptr_dtor(&task); + + zend_atomic_int_dec(&pool->base.running_count); + } + + if (EG(exception)) { + if (!instanceof_function(EG(exception)->ce, async_ce_thread_channel_exception)) { + zend_exception_error(EG(exception), E_WARNING); + } + zend_clear_exception(); + } + + done: + ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false); + + } zend_catch { + bailout = 1; + } zend_end_try(); + + /* Restore execute_data */ + EG(current_execute_data) = fake_frame.prev_execute_data; + + /* Release worker's ref on pool */ + ZEND_THREAD_POOL_DELREF(&pool->base); + + if (bailout) { + zend_bailout(); + } +} + +/////////////////////////////////////////////////////////// +/// Pool lifecycle +/////////////////////////////////////////////////////////// + +static void thread_pool_close(async_thread_pool_t *pool); +static void thread_pool_close_base(zend_async_thread_pool_t *pool); +static void thread_pool_dispose_base(zend_async_thread_pool_t *pool); + +/** + * Create and start a single worker thread. + * On success, stores the thread handle in pool->workers[index] and + * increments pool refcount (+1 for the worker). + * Returns true on success, false on failure. + */ +static bool thread_pool_start_worker(async_thread_pool_t *pool, int32_t index) +{ + zend_async_thread_internal_entry_t *entry = pecalloc(1, sizeof(zend_async_thread_internal_entry_t), 1); + entry->handler = thread_pool_worker_handler; + entry->ctx = pool; + + /* Create thread context (no event for pool workers). + * start_thread will add ref for the runner. */ + zend_async_thread_context_t *context = pecalloc(1, sizeof(zend_async_thread_context_t), 1); + ZEND_ATOMIC_INT_INIT(&context->ref_count, 0); + ZEND_ATOMIC_INT64_INIT(&context->thread_id, 0); + context->snapshot = NULL; + context->bailout_error_message = NULL; + context->event = NULL; + context->internal_entry = NULL; /* set by start_thread */ + + zend_async_thread_handle_t handle = ZEND_ASYNC_START_THREAD(entry, context); + + if (UNEXPECTED(handle == 0)) { + pefree(entry, 1); + pefree(context, 1); + return false; + } + + pool->base.workers[index] = handle; + ZEND_THREAD_POOL_ADDREF(&pool->base); + + return true; +} + +zend_async_thread_pool_t *async_thread_pool_create(int32_t worker_count, int32_t queue_size) +{ + async_thread_pool_t *pool = pecalloc(1, sizeof(async_thread_pool_t), 1); + + pool->base.worker_count = 0; + ZEND_ATOMIC_INT_INIT(&pool->base.pending_count, 0); + ZEND_ATOMIC_INT_INIT(&pool->base.running_count, 0); + ZEND_ATOMIC_INT_INIT(&pool->base.closed, 0); + ZEND_ATOMIC_INT_INIT(&pool->base.ref_count, 1); /* PHP object holds 1 ref */ + + /* Set method pointers */ + pool->base.close = thread_pool_close_base; + pool->base.dispose = thread_pool_dispose_base; + + pool->task_channel = async_thread_channel_create(queue_size); + pool->base.workers = pecalloc(worker_count, sizeof(zend_async_thread_handle_t), 1); + + for (int32_t i = 0; i < worker_count; i++) { + if (UNEXPECTED(false == thread_pool_start_worker(pool, i))) { + pool->base.worker_count = i; + thread_pool_close(pool); + zend_throw_exception(async_ce_thread_pool_exception,"Failed to start worker thread", 0); + return &pool->base; + } + + pool->base.worker_count = i + 1; + } + + return &pool->base; +} + +static void thread_pool_close(async_thread_pool_t *pool) +{ + if (zend_atomic_int_load(&pool->base.closed)) { + return; + } + + zend_atomic_int_store(&pool->base.closed, 1); + + /* Close task channel — workers see closed on next recv and exit */ + if (pool->task_channel != NULL) { + pool->task_channel->channel.close(&pool->task_channel->channel); + } +} + +static void thread_pool_close_base(zend_async_thread_pool_t *base) +{ + thread_pool_close((async_thread_pool_t *) base); +} + +/** + * Drain remaining tasks from channel buffer. + * @param reject If true, reject each task's future with a cancellation exception. + * If false, just release the shared_state ref (for destroy path). + */ +static void thread_pool_drain_tasks(async_thread_pool_t *pool, bool reject) +{ + async_thread_channel_t *ch = pool->task_channel; + if (ch == NULL) { + return; + } + + zval persistent_task; + while (circular_buffer_is_not_empty(&ch->buffer) && + circular_buffer_pop(&ch->buffer, &persistent_task) == SUCCESS) { + + /* Load task to extract pointers */ + zval task; + async_thread_load_zval(&task, &persistent_task); + async_thread_release_transferred_zval(&persistent_task); + + /* [0] = snapshot_ptr, [1] = args, [2] = state_ptr */ + const zval *snapshot_zv = zend_hash_index_find(Z_ARRVAL(task), 0); + if (snapshot_zv != NULL && Z_TYPE_P(snapshot_zv) == IS_LONG) { + async_thread_snapshot_t *snapshot = + (async_thread_snapshot_t *)(uintptr_t) Z_LVAL_P(snapshot_zv); + async_thread_snapshot_destroy(snapshot); + } + + const zval *state_zv = zend_hash_index_find(Z_ARRVAL(task), 2); + if (state_zv != NULL && Z_TYPE_P(state_zv) == IS_LONG) { + zend_future_shared_state_t *state = + (zend_future_shared_state_t *)(uintptr_t) Z_LVAL_P(state_zv); + + if (reject) { + zend_object *exception = async_new_exception( + async_ce_cancellation_exception, + "ThreadPool task was cancelled before execution"); + async_future_shared_state_reject(state, exception); + OBJ_RELEASE(exception); + } + + async_future_shared_state_delref(state); + } + + zend_atomic_int_dec(&pool->base.pending_count); + zval_ptr_dtor(&task); + } +} + +/** + * Destroy the real pool. Called when ref_count reaches 0. + * By this point all workers have exited and released their refs. + */ +static void thread_pool_destroy(async_thread_pool_t *pool) +{ + thread_pool_drain_tasks(pool, false); + + if (pool->task_channel != NULL) { + pool->task_channel->channel.event.dispose(&pool->task_channel->channel.event); + pool->task_channel = NULL; + } + + if (pool->base.workers != NULL) { + pefree(pool->base.workers, 1); + pool->base.workers = NULL; + } + + pefree(pool, 1); +} + +static zend_always_inline void thread_pool_dispose_base(zend_async_thread_pool_t *base) +{ + thread_pool_destroy((async_thread_pool_t *) base); +} + +/////////////////////////////////////////////////////////// +/// PHP object lifecycle +/////////////////////////////////////////////////////////// + +static zend_object *thread_pool_create_object(zend_class_entry *ce) +{ + thread_pool_object_t *obj = zend_object_alloc(sizeof(thread_pool_object_t), ce); + zend_object_std_init(&obj->std, ce); + obj->std.handlers = &thread_pool_handlers; + obj->pool = NULL; + return &obj->std; +} + +static void thread_pool_free_object(zend_object *object) +{ + thread_pool_object_t *obj = ASYNC_THREAD_POOL_FROM_OBJ(object); + + if (obj->pool != NULL) { + /* Close channel so workers exit their receive loop */ + thread_pool_close(obj->pool); + + /* Drop PHP object's ref on the pool. + * Workers hold their own refs — when the last worker finishes + * and does ZEND_THREAD_POOL_DELREF, pool refcount reaches 0 and + * thread_pool_destroy releases pool's refs on worker events. */ + ZEND_THREAD_POOL_DELREF(&obj->pool->base); + obj->pool = NULL; + } + + zend_object_std_dtor(object); +} + +/////////////////////////////////////////////////////////// +/// PHP Methods +/////////////////////////////////////////////////////////// + +METHOD(__construct) +{ + zend_long workers; + zend_long queue_size = 0; + + ZEND_PARSE_PARAMETERS_START(1, 2) + Z_PARAM_LONG(workers) + Z_PARAM_OPTIONAL + Z_PARAM_LONG(queue_size) + ZEND_PARSE_PARAMETERS_END(); + + if (workers < 1) { + zend_argument_value_error(1, "must be >= 1"); + RETURN_THROWS(); + } + + if (queue_size <= 0) { + queue_size = workers * 4; + } + + thread_pool_object_t *obj = ASYNC_THREAD_POOL_FROM_OBJ(Z_OBJ_P(ZEND_THIS)); + obj->pool = (async_thread_pool_t *) async_thread_pool_create((int32_t) workers, (int32_t) queue_size); +} + +METHOD(submit) +{ + zend_fcall_info fci; + zend_fcall_info_cache fcc; + zval *args = NULL; + int args_count = 0; + + ZEND_PARSE_PARAMETERS_START(1, -1) + Z_PARAM_FUNC(fci, fcc) + Z_PARAM_VARIADIC('+', args, args_count) + ZEND_PARSE_PARAMETERS_END(); + + async_thread_pool_t *pool = THIS_POOL(); + + if (UNEXPECTED(pool == NULL)) { + zend_throw_exception(async_ce_thread_pool_exception, "ThreadPool not initialized", 0); + RETURN_THROWS(); + } + + if (UNEXPECTED(zend_atomic_int_load(&pool->base.closed))) { + zend_throw_exception(async_ce_thread_pool_exception, "ThreadPool is closed", 0); + RETURN_THROWS(); + } + + /* 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); + + if (UNEXPECTED(snapshot == NULL)) { + zend_throw_exception(async_ce_thread_pool_exception, "Failed to create task snapshot", 0); + RETURN_THROWS(); + } + + /* 2. Create shared_state + remote_future (holds trigger in parent thread) */ + zend_future_shared_state_t *state = async_future_shared_state_create(); + zend_future_remote_t *remote = async_new_remote_future(state); + + if (UNEXPECTED(remote == NULL)) { + async_thread_snapshot_destroy(snapshot); + async_future_shared_state_destroy(state); + zend_throw_exception(async_ce_thread_pool_exception, "Failed to create future", 0); + RETURN_THROWS(); + } + + /* +1 ref for the task — worker will delref after complete/reject */ + async_future_shared_state_addref(state); + + /* 3. Pack task: [snapshot_ptr, args_array, state_ptr] */ + zval task; + array_init_size(&task, 3); + + zval snapshot_zv; + ZVAL_LONG(&snapshot_zv, (zend_long)(uintptr_t) snapshot); + zend_hash_next_index_insert_new(Z_ARRVAL(task), &snapshot_zv); + + zval args_arr; + array_init_size(&args_arr, args_count); + for (int i = 0; i < args_count; i++) { + zval arg_copy; + ZVAL_COPY(&arg_copy, &args[i]); + zend_hash_next_index_insert_new(Z_ARRVAL(args_arr), &arg_copy); + } + zend_hash_next_index_insert_new(Z_ARRVAL(task), &args_arr); + + zval state_zv; + ZVAL_LONG(&state_zv, (zend_long)(uintptr_t) state); + zend_hash_next_index_insert_new(Z_ARRVAL(task), &state_zv); + + /* 4. Send through channel (may suspend if full — backpressure) */ + if (!pool->task_channel->channel.send(&pool->task_channel->channel, &task)) { + zval_ptr_dtor(&task); + async_thread_snapshot_destroy(snapshot); + async_future_shared_state_delref(state); + ZEND_ASYNC_EVENT_RELEASE(&remote->future.event); + if (!EG(exception)) { + zend_throw_exception(async_ce_thread_pool_exception, "ThreadPool channel is closed", 0); + } + RETURN_THROWS(); + } + + zval_ptr_dtor(&task); + zend_atomic_int_inc(&pool->base.pending_count); + + /* 4. Return Future PHP object */ + ZEND_FUTURE_SET_USED(&remote->future); + zend_object *future_obj = async_new_future_obj(&remote->future); + RETURN_OBJ(future_obj); +} + +METHOD(map) +{ + zval *items; + zend_fcall_info fci; + zend_fcall_info_cache fcc; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_ARRAY(items) + Z_PARAM_FUNC(fci, fcc) + ZEND_PARSE_PARAMETERS_END(); + + async_thread_pool_t *pool = THIS_POOL(); + + if (UNEXPECTED(pool == NULL)) { + zend_throw_exception(async_ce_thread_pool_exception, "ThreadPool not initialized", 0); + RETURN_THROWS(); + } + + if (UNEXPECTED(zend_atomic_int_load(&pool->base.closed))) { + zend_throw_exception(async_ce_thread_pool_exception, "ThreadPool is closed", 0); + RETURN_THROWS(); + } + + HashTable *ht = Z_ARRVAL_P(items); + uint32_t count = zend_hash_num_elements(ht); + + if (count == 0) { + array_init(return_value); + return; + } + + /* Submit a task for each item, collecting futures keyed by original keys */ + zval futures_arr; + array_init_size(&futures_arr, count); + + zend_string *str_key; + zend_ulong num_key; + zval *item; + + 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); + + if (UNEXPECTED(snapshot == NULL)) { + zval_ptr_dtor(&futures_arr); + zend_throw_exception(async_ce_thread_pool_exception, "Failed to create task snapshot", 0); + RETURN_THROWS(); + } + + zend_future_shared_state_t *state = async_future_shared_state_create(); + zend_future_remote_t *remote = async_new_remote_future(state); + + if (UNEXPECTED(remote == NULL)) { + async_thread_snapshot_destroy(snapshot); + async_future_shared_state_destroy(state); + zval_ptr_dtor(&futures_arr); + zend_throw_exception(async_ce_thread_pool_exception, "Failed to create future", 0); + RETURN_THROWS(); + } + + async_future_shared_state_addref(state); + + /* Pack task: [snapshot_ptr, args_array(1 item), state_ptr] */ + zval task; + array_init_size(&task, 3); + + zval snapshot_zv; + ZVAL_LONG(&snapshot_zv, (zend_long)(uintptr_t) snapshot); + zend_hash_next_index_insert_new(Z_ARRVAL(task), &snapshot_zv); + + zval args_arr; + array_init_size(&args_arr, 1); + zval arg_copy; + ZVAL_COPY(&arg_copy, item); + zend_hash_next_index_insert_new(Z_ARRVAL(args_arr), &arg_copy); + zend_hash_next_index_insert_new(Z_ARRVAL(task), &args_arr); + + zval state_zv; + ZVAL_LONG(&state_zv, (zend_long)(uintptr_t) state); + zend_hash_next_index_insert_new(Z_ARRVAL(task), &state_zv); + + if (!pool->task_channel->channel.send(&pool->task_channel->channel, &task)) { + zval_ptr_dtor(&task); + async_thread_snapshot_destroy(snapshot); + async_future_shared_state_delref(state); + ZEND_ASYNC_EVENT_RELEASE(&remote->future.event); + zval_ptr_dtor(&futures_arr); + if (!EG(exception)) { + zend_throw_exception(async_ce_thread_pool_exception, "ThreadPool channel is closed", 0); + } + RETURN_THROWS(); + } + + zval_ptr_dtor(&task); + zend_atomic_int_inc(&pool->base.pending_count); + + ZEND_FUTURE_SET_USED(&remote->future); + zend_object *future_obj = async_new_future_obj(&remote->future); + + zval future_zv; + ZVAL_OBJ(&future_zv, future_obj); + + if (str_key) { + zend_hash_add_new(Z_ARRVAL(futures_arr), str_key, &future_zv); + } else { + zend_hash_index_add_new(Z_ARRVAL(futures_arr), num_key, &future_zv); + } + } ZEND_HASH_FOREACH_END(); + + /* Await all futures */ + HashTable *results = zend_new_array(count); + + async_await_futures(&futures_arr, + (int) count, + false, + NULL, + 0, + 0, + results, + NULL, + false, + true, + false); + + zval_ptr_dtor(&futures_arr); + + if (EG(exception)) { + zend_array_release(results); + RETURN_THROWS(); + } + + RETURN_ARR(results); +} + +METHOD(close) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + async_thread_pool_t *pool = THIS_POOL(); + if (pool != NULL) { + thread_pool_close(pool); + } +} + +METHOD(cancel) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + async_thread_pool_t *pool = THIS_POOL(); + if (pool == NULL) { + return; + } + + thread_pool_close(pool); + /* Reject futures of tasks that had not yet been picked up by a worker. + * Running tasks are allowed to finish naturally — cancel() only covers + * the backlog, not already-in-flight computations. */ + thread_pool_drain_tasks(pool, /*reject*/ true); +} + +METHOD(isClosed) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + async_thread_pool_t *pool = THIS_POOL(); + RETURN_BOOL(pool == NULL || zend_atomic_int_load(&pool->base.closed)); +} + +METHOD(getPendingCount) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + async_thread_pool_t *pool = THIS_POOL(); + RETURN_LONG(pool ? zend_atomic_int_load(&pool->base.pending_count) : 0); +} + +METHOD(getRunningCount) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + async_thread_pool_t *pool = THIS_POOL(); + RETURN_LONG(pool ? zend_atomic_int_load(&pool->base.running_count) : 0); +} + +METHOD(count) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + async_thread_pool_t *pool = THIS_POOL(); + if (pool == NULL) { + RETURN_LONG(0); + } + RETURN_LONG(zend_atomic_int_load(&pool->base.pending_count) + zend_atomic_int_load(&pool->base.running_count)); +} + +METHOD(getWorkerCount) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + async_thread_pool_t *pool = THIS_POOL(); + RETURN_LONG(pool ? pool->base.worker_count : 0); +} + +/////////////////////////////////////////////////////////// +/// Class registration +/////////////////////////////////////////////////////////// + +void async_register_thread_pool_ce(void) +{ + async_ce_thread_pool = register_class_Async_ThreadPool(zend_ce_countable); + async_ce_thread_pool->create_object = thread_pool_create_object; + + memcpy(&thread_pool_handlers, &std_object_handlers, sizeof(zend_object_handlers)); + thread_pool_handlers.offset = XtOffsetOf(thread_pool_object_t, std); + thread_pool_handlers.free_obj = thread_pool_free_object; + async_ce_thread_pool->default_object_handlers = &thread_pool_handlers; + + async_ce_thread_pool_exception = register_class_Async_ThreadPoolException(zend_ce_exception); +} diff --git a/thread_pool.h b/thread_pool.h new file mode 100644 index 00000000..48dc85d5 --- /dev/null +++ b/thread_pool.h @@ -0,0 +1,61 @@ +/* ++----------------------------------------------------------------------+ + | Copyright (c) The PHP Group | + +----------------------------------------------------------------------+ + | This source file is subject to version 3.01 of the PHP license, | + | that is bundled with this package in the file LICENSE, and is | + | available through the world-wide-web at the following url: | + | https://www.php.net/license/3_01.txt | + | If you did not receive a copy of the PHP license and are unable to | + | obtain it through the world-wide-web, please send a note to | + | license@php.net so we can mail you a copy immediately. | + +----------------------------------------------------------------------+ + | Author: Edmond | + +----------------------------------------------------------------------+ +*/ +#ifndef ASYNC_THREAD_POOL_H +#define ASYNC_THREAD_POOL_H + +#include "php_async_api.h" +#include "future.h" +#include "thread_channel.h" +#include + +/////////////////////////////////////////////////////////// +/// Thread pool (persistent memory, shared between threads) +/////////////////////////////////////////////////////////// + +typedef struct _async_thread_pool_s async_thread_pool_t; + +struct _async_thread_pool_s { + /* Base structure (must be first for casting) */ + zend_async_thread_pool_t base; + + /* Task channel (shared, persistent memory) */ + async_thread_channel_t *task_channel; +}; + +/////////////////////////////////////////////////////////// +/// PHP object wrapper (emalloc, per-thread) +/////////////////////////////////////////////////////////// + +typedef struct _thread_pool_object_s { + async_thread_pool_t *pool; /* pemalloc'd, shared */ + zend_object std; /* must be last */ +} thread_pool_object_t; + +/* Class entries */ +extern zend_class_entry *async_ce_thread_pool; +extern zend_class_entry *async_ce_thread_pool_exception; + +/* Convert zend_object to thread_pool_object_t */ +#define ASYNC_THREAD_POOL_FROM_OBJ(obj) \ + ((thread_pool_object_t *)((char *)(obj) - XtOffsetOf(thread_pool_object_t, std))) + +/* Factory — creates a new thread pool (returns base pointer for API registration) */ +zend_async_thread_pool_t *async_thread_pool_create(int32_t worker_count, int32_t queue_size); + +/* Registration function */ +void async_register_thread_pool_ce(void); + +#endif /* ASYNC_THREAD_POOL_H */ diff --git a/thread_pool.stub.php b/thread_pool.stub.php new file mode 100644 index 00000000..a77f226d --- /dev/null +++ b/thread_pool.stub.php @@ -0,0 +1,74 @@ + +#endif + +/* + * Cross-platform mutex helpers for the async extension. + * + * Backed by TSRM's MUTEX_T under ZTS (CRITICAL_SECTION* on Windows, + * pthread_mutex_t* on POSIX). In non-ZTS builds the macros expand + * to no-ops — cross-thread primitives have no meaning without TSRM, + * and the whole threading feature of the extension is ZTS-only. + * + * API: + * ASYNC_MUTEX_INIT(ref) ref = tsrm_mutex_alloc(); (no-op in NTS) + * ASYNC_MUTEX_DESTROY(ref) tsrm_mutex_free(ref); ref = NULL; + * ASYNC_MUTEX_LOCK(ref) tsrm_mutex_lock(ref); + * ASYNC_MUTEX_UNLOCK(ref) tsrm_mutex_unlock(ref); + * + * Note: MUTEX_T is a pointer type. Pass the field directly + * (e.g. state->mutex), not its address. + * + * Field declaration in a struct is intentionally NOT hidden behind a + * macro — declare it inline with `#ifdef ZTS MUTEX_T name; #endif` + * so that no public header has to pull in zend_common.h. + */ +#ifdef ZTS +# define ASYNC_MUTEX_INIT(ref) do { (ref) = tsrm_mutex_alloc(); } while (0) +# define ASYNC_MUTEX_DESTROY(ref) do { if ((ref) != NULL) { tsrm_mutex_free(ref); (ref) = NULL; } } while (0) +# define ASYNC_MUTEX_LOCK(ref) tsrm_mutex_lock(ref) +# define ASYNC_MUTEX_UNLOCK(ref) tsrm_mutex_unlock(ref) +#else +# define ASYNC_MUTEX_INIT(ref) ((void) 0) +# define ASYNC_MUTEX_DESTROY(ref) ((void) 0) +# define ASYNC_MUTEX_LOCK(ref) ((void) 0) +# define ASYNC_MUTEX_UNLOCK(ref) ((void) 0) +#endif + #define IF_THROW_RETURN_VOID \ if (UNEXPECTED(EG(exception) != NULL)) { \ return; \