Skip to content

98 thread pool#100

Merged
EdmondDantes merged 84 commits intomainfrom
98-thread-pool
Apr 16, 2026
Merged

98 thread pool#100
EdmondDantes merged 84 commits intomainfrom
98-thread-pool

Conversation

@EdmondDantes
Copy link
Copy Markdown
Contributor

No description provided.

Add zend_atomic_int64 and thread event API for spawn_thread support
… support

     Remove full function/class table deep copy from snapshot. Child thread now recompiles code on demand via autoloader. Add two-phase zval transfer between threads (pemalloc as intermediate persistent storage). Track all pemalloc allocations for proper bulk cleanup in snapshot_destroy. Fix memory safety in child thread closure creation: prevent destroy_op_array on pemalloc'd data via refcount guard, avoid leaked intermediate static_variables and runtime cache.
Bootloader is responsible for setting up its own autoloaders,
so there is no need to transfer them from the parent thread.
Autoloader closures cannot be transferred between threads without
deep-copying their op_arrays. Users must provide a bootloader
closure that sets up autoloading in the child thread.
- Fix dispose crash when notify_cb hasn't fired: use result_loaded
  flag to distinguish pemalloc vs emalloc data for proper cleanup
- Fix exit_code=0 when zend_call_function succeeds but throws exception
- Deduplicate exception transfer code via thread_transfer_exception label
- Remove dead code: fcall/bootloader release in dispose (now in snapshot)
- Add ZEND_ASSERT for closure copy func pointer
- Add ZEND_UNREACHABLE for non-ZTS code path
- Validate entry param as Closure via Z_PARAM_OBJECT_OF_CLASS
- Error on IS_REFERENCE and IS_RESOURCE in thread_transfer_zval_inner
- Error on unknown zval types in thread_transfer_zval_inner
- Reorder sections: transfer → load → release for better readability
- Document ecalloc usage for thread event allocation
- Fix include order: replace php_main.h + php_globals.h with php.h
  to ensure PHPAPI is defined before php_globals.h is parsed
- Remove dead code: internal_entry branch in libuv_thread_entry
  (field does not exist in zend_async_thread_event_t)
- Remove unused zend_autoload.h include
- Update comments to reflect autoloader removal from snapshot
Move thread entry (async_thread_run), request startup/shutdown, and
closure creation from libuv_reactor.c to thread.c. Reactor now calls
thread lifecycle through API macros (ZEND_ASYNC_THREAD_SNAPSHOT_CREATE,
ZEND_ASYNC_THREAD_SNAPSHOT_DESTROY, zend_async_thread_run_fn).

Register thread lifecycle in scheduler via async_API.c.
Move parent SAPI context into snapshot. Remove libuv_thread_entry
wrapper — async_thread_run passed directly to uv_thread_create.
Remove obsolete thread tests for code cloning.
Fix pefree crash on non-pemalloc pointers: thread_xlat_put stored
original (emalloc/interned) pointers in persistent_map, destructor
tried to pefree them. Remove thread_xlat_put (dead code after class
copy removal). Use HashTable destructor (thread_persistent_ptr_dtor)
for automatic cleanup instead of manual iteration loop.
- Set op_array refcount to NULL instead of emalloc'd counter.
  zend_create_closure and destroy_op_array both check for NULL
  and skip increment/cleanup, avoiding leak and preventing
  efree on pemalloc'd op_array internals.
- Set SG(request_info).path_translated from closure filename
  so error messages show the correct script path.
- Free path_translated before php_request_shutdown since it's
  our allocation, not SAPI-owned.
Replace thread_transferred_object_t with direct zend_object memcpy.
Repurpose ce/handlers fields for transit (class_name/prop_count).
Load objects via zend_lookup_class (autoload) in parent thread.

Move result/exception loading from libuv_reactor.c to thread.c as
async_thread_load_result(), called via ZEND_ASYNC_THREAD_LOAD_RESULT
API macro. Remove result_loaded bool from async_thread_event_t,
use ZEND_THREAD_F_RESULT_LOADED event flag instead.

Remove dynamic properties support from object transfer (deprecated
since PHP 8.2).
- RemoteException wraps all child thread exceptions with clear thread
  boundary marker. Stores original exception and remote class name.
- ThreadTransferException for data transfer failures (autoload errors,
  class not found, nesting depth exceeded).
- async_thread_load_result handles both success and failure paths:
  wraps loaded exceptions in RemoteException, creates ThreadTransferException
  on autoload/load failures with original error as previous.
- Add recursion depth protection (THREAD_TRANSFER_MAX_DEPTH) for
  transfer/load of nested arrays and objects.
- Fix const qualifiers, improve variable naming throughout thread.c.
- Add THREAD_TRANSFER_CHECK_DEPTH macro to reduce code duplication.
- Replace zend_call_function with zend_execute_ex in thread_call_closure
  to prevent uncaught exceptions from triggering fatal bailout (no
  current_execute_data = nowhere to propagate = bailout).
- Split async_thread_request_startup: extract async_thread_tsrm_init()
  for TSRM initialization before zend_try (EG(bailout) not available
  until TSRM is initialized). Use zend_first_try for initial block.
- Add bailout recovery: capture PG(last_error_message) after zend_catch,
  convert to ThreadTransferException in parent via bailout_error_message.
- Pre-allocate fallback error message for OOM bailout safety.
- Fix ZEND_THREAD_SET_RESULT_LOADED not set on exception-only path.
- Wrap async_thread_request_shutdown in zend_try, call ts_free_thread
  separately after all zend_end_try blocks.
- Add uv_unref for async handle in stop to prevent event loop hang.
- Remove exit_code field, update tests for RemoteException.
- Use zend_async_throw(ZEND_ASYNC_EXCEPTION_THREAD_TRANSFER) instead of
  direct zend_throw_exception_ex for dynamic properties error.
- Register thread exception classes in get_class_ce switch.
- Handle EG(exception) after async_thread_transfer_zval in async_thread_run
  to catch transfer errors (unsupported types, depth limit).
- Fix zend_exception_set_previous usage in thread_create_transfer_exception.
- Add NULL checks for failed thread_transfer_object/hash_table.
- Reject objects with dynamic properties (stdClass) with clear error.
- Add 18 new thread tests: global context, scalar types, string/array
  return, empty array, duplicate strings, stdClass rejection, bootloader
  exception, bailout/exit, depth limit, captured variables, void return.
Destroy snapshot in async_thread_run after closures finish, instead of
in dispose (parent thread). Snapshot is no longer needed once closures
have been called — all pemalloc'd op_array data has been consumed.

Dispose retains a safety-net check for cases where child never started.
This also avoids potential cross-thread heap issues with persistent_map
destructor running in the parent's context.
GCC cannot inline mutually recursive functions — remove zend_always_inline
from thread_release_transferred_zval and thread_release_transferred_hash_table.
…tributes

thread_copy_hash_table() was called on the original HashTable before
copying its struct to persistent memory, corrupting the original's arData
pointer. When the snapshot was destroyed, the original HashTable was left
with a dangling pointer, causing use-after-free in the parent thread
during closure cleanup.

Fix: copy the HashTable struct first (thread_persist_copy_xlat), then
call thread_copy_hash_table on the persistent copy. Same pattern already
used correctly for IS_ARRAY in thread_copy_zval.
When xlat table returns an existing copy (string/HashTable/object),
the pointer was returned without incrementing refcount. Multiple
zvals pointing to the same persistent copy would cause double-free
on release.

- Add GC_ADDREF on xlat hit in thread_transfer_string/hash_table/object
- Add GC_SET_REFCOUNT(dst, 1) after memcpy in thread_transfer_object
- Add GC_DELREF check in thread_release_transferred_hash_table/object
- Fix test 023: array literal copies values, use $arr[0] = &$a instead
Individual pemalloc calls for opcodes, literals, strings etc. placed
them at arbitrary addresses in the heap. On ASAN builds (and potentially
in production), the distance between opcodes and literals could exceed
int32_t range, causing RT_CONSTANT relative offset overflow — the VM
would read garbage instead of the correct literal.

Replace the persistent_map (HashTable of individually pemalloc'd
pointers freed via dtor) with a bump allocator arena:
- Single large pemalloc block, grown as needed (64KB initial, 2x)
- All op_array data (opcodes, literals, strings, arg_info, etc.)
  allocated contiguously within the arena
- RT_CONSTANT offsets guaranteed small (within same arena block)
- Snapshot destroy frees entire arena at once (no per-pointer iteration)
- bound_vars remain as individual pemalloc (released separately)

Also adds zend_always_inline on hot-path arena_alloc and xlat_get,
EXPECTED/UNEXPECTED branch hints for fast path.
Closures created in child thread (e.g. autoloader callbacks registered
by bootloader) are destroyed during php_request_shutdown. Their op_arrays
reference strings in the snapshot arena. If snapshot is destroyed first,
request shutdown accesses freed arena memory (use-after-free).

Swap order: request shutdown first, then snapshot arena free.
… large data

- 028: calling built-in functions (sort, implode, array_map) inside thread
- 029: static variables in closure isolated per thread
- 030: sequential stress (10 threads with string returns)
- 031: concurrent stress (8 parallel threads)
- 032: nested closures and array_map/array_reduce
- 033: exception type propagation (TypeError, DivisionByZero, custom)
- 034: large data transfer (100KB string, 1000-element array, nested)
- 035: parent globals not visible in thread
- 036: mixed scalar types in associative array
- 033: use getRemoteClass() and getRemoteException() (correct API)
- 035: simplify to test only parent globals isolation (superglobals
  in child thread need separate feature work)
Child threads execute pre-compiled op_arrays (no compiler runs), so
auto_globals_jit callbacks never fire. Call zend_is_auto_global()
for _SERVER and _ENV after request startup to force initialization.
…d messaging

Implements Async\ThreadChannel with persistent memory (pemalloc) structure
separated from PHP object wrapper. Basic send/recv with pthread_mutex
protection and zval transfer via async_thread_transfer_zval.

Supports: constructor, send, recv, close, isClosed, capacity, count,
isEmpty, isFull. Back-pressure (suspend on full/empty) and cross-thread
uv_async_send notification not yet implemented.
…init

- Replace waiter queues with trigger event HashTables (per-thread)
- Use zend_async_trigger_event_t for cross-thread notification
- Use zend_hash_index_find_ptr/add_ptr instead of manual zval wrapping
- Add trigger_dtor for automatic cleanup on zend_hash_destroy
- Fix event_init: remove ZEND_ASYNC_EVENT_F_ZEND_OBJ flag — event lives
  in persistent memory, separate from zend_object (different allocations)
- Simplify ensure_trigger to single lock
- Back-pressure logic (suspend on full/empty) implemented but not yet
  working — coroutine does not resume after send to non-full buffer
- task_group/035: gc_get handler walks task entries in PENDING / RUNNING
                  / ERROR states. Closes the task_group_object_gc loop
                  body that was previously unreached.
- thread/041:    status accessors and finally() — exercises isRunning,
                  isCompleted, isCancelled, getResult, getException,
                  cancel ("not yet implemented") and the synchronous
                  finally() path on an already-completed thread.
- thread_pool/031: closure transferring with try/catch, static vars and
                  a nested function definition exercises the op_array
                  field-rebinding branches in thread_persist_function_callback.
- context/007:   three-level scope hierarchy walks past intermediate
                  contexts; covers the loop iteration in async_context_find()
                  that advances scope = scope->parent_scope.
…n, MINFO

- info/003: phpinfo() output for the async module — covers PHP_MINFO_FUNCTION.
- common/timeout_class_methods: forbidden direct construct, isCompleted,
              isCancelled and idempotent cancel() on Async\Timeout — also
              triggers async_timeout_object_create() factory.
- signal/004: Async\signal() with an already-resolved cancellation Future
              — covers the IS_CLOSED fast-path that returns an immediately
              rejected Future. (Existing 003 used a Timeout that never
              actually transitioned to CLOSED, so this branch was dark.)
- edge_cases/014: explicit Async\graceful_shutdown() call from userland
              — covers PHP_FUNCTION(Async_graceful_shutdown) entry.
- iterate/type_error_invalid_argument: Async\iterate() with a non-iterable
              first argument throws TypeError, covering the type-error
              branch in PHP_FUNCTION(Async_iterate).

Raises async.c line coverage from 76.5% to 84.5% (+8%).
Records the final numbers (74.3% → 77.45% lines across 28 new phpt
tests in 5 commits), the per-file delta, the three real bugs that
coverage testing surfaced (disposeAfterTimeout ref leak,
composite_exception properties_table[7] corruption,
pool_strategy_report_failure use-after-free), and the categorised
dead-zone map that explains the phpt-only ceiling at ~80-84%.

Companion to COVERAGE_PLAN.md, which holds the initial gap survey.
- pool.c: pool_strategy_report_failure() no longer captures a dangling
  exception pointer. Generic Exception is now constructed directly via
  object_init_ex(zend_ce_exception) + update_property(MESSAGE) instead
  of zend_throw_exception + zend_clear_exception, and its lifetime is
  managed locally via an owns_error flag and zval_ptr_dtor.

- scope.c: disposeAfterTimeout() no longer leaks the scope refcount.
  The timeout callback gets a custom dispose handler that releases
  the scope ref when the callback is freed before firing; on the fire
  path, scope_timeout_callback() transfers ownership to the spawned
  cancellation coroutine, which releases after SCOPE_CANCEL. Raw
  ref_count++ was replaced with ZEND_ASYNC_EVENT_ADD_REF, and the
  previously-silent add_callback failure branch now releases the ref
  and frees the unclaimed callback.

- exceptions.c: async_composite_exception_add_exception() no longer
  writes to hard-coded properties_table[7]. Reads and writes go
  through zend_read_property / zend_update_property using the
  property name, so the engine resolves the correct typed-property
  slot. getExceptions() switched to silent=1 so an empty composite
  reads back as [] instead of the typed-uninit fatal. Fixed a second
  latent bug: the PHP addException() method was passing transfer=true
  to the helper even though Z_PARAM_OBJECT_OF_CLASS only lends a
  borrowed reference, which caused stored zvals to alias to the
  last-inserted object once slot-7 corruption stopped masking it.

Tests:
- tests/pool/052-pool_strategy_report_failure_before_release.phpt
  exercises reportFailure() via a pool with beforeRelease returning
  false and verifies the strategy receives a well-formed Exception.
- tests/edge_cases/013-composite_exception_direct.phpt expanded from
  a single-add stub to cover empty-read, three adds of different
  classes, and class/message round-trip.

COVERAGE_REPORT.md updated with per-bug Fix paragraphs and §7 ROI
list now marks the three-bug cleanup as done.
Async\Timeout::cancel() disposed the backing timer event, whose
async_timeout_event_dispose() calls OBJ_RELEASE(object) assuming the
event holds 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
hit "IS_OBJ_VALID(objects_store.object_buckets[handle])" in
zend_objects_store_del().

Mirror async_timeout_destroy_object() by clearing timeout_ext->std
before dispatching the dispose, so the dispose handler sees a NULL
std and skips the stray OBJ_RELEASE.

Unblocks tests/common/timeout_class_methods.phpt. Full async suite
now clean (833 passed, 0 failed, 1 pre-existing XFAIL-passes warn).
Add 7 phpt tests for future.c gaps identified in the gcov run:

- 031 isCompleted/isCancelled across pending, completed, rejected and
  AsyncCancellation-rejected states.
- 032 Future::cancel() default, custom cancellation and already-completed
  no-op branch.
- 033 Future::getAwaitingInfo() returns the FutureState info string.
- 034 FutureState::complete()/error() double-resolve error paths —
  exercises "already completed at %s:%d" location reporting.
- 035 finally() handler throwing on a rejected parent chains the parent
  as previous via zend_exception_set_previous().
- 036 map()/catch()/finally() TypeError on non-callable argument.
- 037 finally() on Future::completed()/failed() hits the eager-spawn
  branch in async_future_create_mapper().

New coverage tracking file COVERAGE_PROGRESS.md carries forward the
§6 achievable budget from COVERAGE_REPORT.md and marks target #1 done.
Add 7 phpt tests for small surface-area gaps in async.c:

- iterate/014 IteratorAggregate::getIterator() throwing propagates
  through Async\iterate().
- common/timeout_value_error Async\timeout(0/-1/-1000) → ValueError.
- common/await_any_of_exception_releases_arrays non-iterable futures
  arg exercises the results/errors release branch.
- common/current_coroutine_not_in_coroutine at script root.
- common/current_context_at_root Async\current_context() and
  Async\coroutine_context() at script root.
- common/await_same_cancellation passing the same event as awaitable
  and cancellation.
- sleep/003-delay_zero_immediate Async\delay(0) fast path.

The §6 report's "~50 lines" estimate for iterate() turned out to be
optimistic — most of the remaining gap is cancel-pending exception
merge paths that require both the iterator callback AND the cancel
path to throw simultaneously, plus fault-injection branches.
COVERAGE_PROGRESS.md target #2 marked done.
Add 5 phpt tests for uncovered TaskGroup synchronous-settled branches
and small error surface:

- 035 all() synchronous-reject when all tasks already failed before
  the all() call.
- 036 race() synchronous-reject on first already-errored task.
- 037 any() synchronous all-failed reject via CompositeException.
- 038 bundle: empty any(), negative __construct concurrency, duplicate
  integer spawnWithKey key.
- 039 direct getIterator() call hits the "invalid state" PHP method
  body — foreach goes through the get_iterator handler instead.

Notes in COVERAGE_PROGRESS.md:
- Latent segfault: keeping $future = $group->all() across a try/catch
  crashes at teardown in the synchronously-settled path (worked
  around in test 035).
- Dead code: L1305-1307 "Cannot spawn on a completed TaskGroup" is
  shadowed by the earlier IS_SEALED check — the COMPLETED flag is
  only set on sealed groups, so that branch is unreachable.
Add 2 phpt tests for channel iteration gaps:

- 039 foreach on an unbuffered (rendezvous) channel — existing test
  008 only covered the buffered zval_circular_buffer_pop branch.
- 040 foreach ($ch as &$v) → "Cannot iterate channel by reference"
  hits channel_get_iterator() by_ref error branch.

COVERAGE_PROGRESS.md target #4 marked done. Notes: direct
$channel->getIterator() call hits the METHOD body but fails the
declared `: Iterator` return type with a fatal error (the method
returns an __iterator_wrapper internally), so it can't be covered
from phpt.
…l, context)

Close out phase 2 of the achievable-coverage budget from §6 of the
COVERAGE_REPORT. Final aggregate: lines 77.45% → 78.34% (+104 lines),
functions 88% → 89.1% (+10 functions).

- thread/042-thread_finally_non_callable — finally() non-callable
  rejection (thread.c L2309-2311). The §6 "~40 lines" estimate turned
  out to be mostly defensive dead code behind thread_event==NULL and
  bailout paths; attempting to register finally on a still-running
  thread surfaces a separate latent SIGSEGV (logged).

- fs_watcher/target #6 SKIPPED: the RENAME-on-existing-CHANGE coalesce
  merge branch can't be triggered from userland on Linux inotify —
  rename() emits MOVED_FROM/MOVED_TO with a different filename than
  the preceding CHANGE, so the coalesce key doesn't match.

- thread_pool/032-map_on_closed_pool — map() on a closed pool throws
  ThreadPoolException (thread_pool.c L515-522).

- context/008-context_get_missing — Context::get() returning null for
  a missing key (context.c L242).

COVERAGE_PROGRESS.md updated with final lcov summary and per-target
notes explaining what remains and why (mostly fault-injection,
deadlock-reporter info() helpers, and C-API-only error branches
behind already-validated PHP-method type guards).
1. TaskGroup all()/race()/any() synchronous-settled use-after-free

   The synchronously-settled fast paths in METHOD(all), METHOD(race)
   and METHOD(any) created a waiter via task_group_waiter_future_new()
   (which pushed it into group->waiter_events[]), resolved it, 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-disposes everything
   still in waiter_events[], which efrees the waiter. When the Future
   wrapper is then destroyed and releases the waiter it had wrapped,
   it touches freed memory → "Future was never used" warning from a
   stale zend_future_t followed by a segfault.

   Fix: call task_group_waiter_event_remove(waiter) at the end of
   each synchronous-resolve branch, matching what
   task_group_try_complete() does. Verified the intermediate-
   $future form now runs cleanly (tests/task_group/035 expanded to
   keep the intermediate variable).

2. Thread::finally() on a still-running thread NULL-scope crash

   thread_object_dtor() dispatches registered finally handlers via
   async_call_finally_handlers(). That helper unconditionally
   dereferences context->scope:
       zend_async_new_scope_fn(context->scope, false)
       ZEND_ASYNC_EVENT_ADD_REF(&context->scope->event)
   but thread.c was passing context->scope = NULL because the Thread
   object has no PHP-side scope of its own.

   Fix: borrow ZEND_ASYNC_MAIN_SCOPE as the run scope. If the async
   subsystem is already off (ZEND_ASYNC_IS_OFF — e.g. dtor firing
   during late zend_call_destructors), release the handlers
   synchronously without spawning anything.

   Also added thread_finally_handlers_dtor() so the GC_ADDREF that
   keeps the Thread alive during handler execution gets paired with
   an OBJ_RELEASE. Previously context->dtor was NULL and the Thread
   object leaked 72 bytes every time dtor-time finally ran.

   New test tests/thread/042-thread_finally_dispatch covers both
   the non-callable rejection and the previously-crashing still-
   running registration path.

Both bugs were identified and logged during coverage phase 2 in
COVERAGE_PROGRESS.md; they no longer block reachable coverage.
…cope

Previous fix borrowed ZEND_ASYNC_MAIN_SCOPE as a workaround when
dispatching Thread finally handlers, which broke scope semantics —
handlers ran detached from whatever scope the thread was spawned in,
losing inherited exception handlers and context values.

Capture the active scope at spawn time and hold a refcount on its
event so it outlives the Thread object. Release in thread_object_free().
The finally-handler dispatcher now uses this captured scope as the
parent, so handlers run as children of the caller's scope hierarchy
exactly like coroutine / task_group / scope finally do.

Changes:
- async_thread_object_t: new parent_scope field
- Async_spawn_thread(): capture ZEND_ASYNC_CURRENT_SCOPE, ADDREF
- thread_object_create(): init parent_scope = NULL
- thread_object_dtor(): use thread->parent_scope instead of MAIN_SCOPE
- thread_object_free(): release parent_scope ref

The ZEND_ASYNC_IS_OFF guard is kept alongside a parent_scope==NULL
check as a safety net for the edge case where a Thread outlives the
async subsystem (late zend_call_destructors after RSHUTDOWN).
@EdmondDantes EdmondDantes linked an issue Apr 15, 2026 that may be closed by this pull request
@EdmondDantes EdmondDantes self-assigned this Apr 15, 2026
@EdmondDantes EdmondDantes added the enhancement New feature or request label Apr 15, 2026
@EdmondDantes EdmondDantes moved this to In Progress in True Async Board Apr 15, 2026
@EdmondDantes EdmondDantes added this to the TrueAsync 0.7.0 milestone Apr 15, 2026
async_thread_snapshot_create() did not check EG(exception) after deep-copying
captured variables, so a transfer_obj handler that threw (e.g. the second
transfer of FutureState) still produced a "successful" snapshot with IS_NULL
in place of the offending variable. libuv_new_thread_event() then started
the child thread; on return from spawn_thread PHP unwound the pending
exception, releasing the Thread object and freeing the event, while the
worker was still inside thread_call_closure writing to event->exception.

Check EG(exception) in snapshot_create, destroy the partial snapshot and
return NULL on failure. libuv_new_thread_event now rolls back and returns
NULL too, so the thread is never launched. Fixes the heap-use-after-free
caught by ASAN in remote_future/006-future_state_transfer_multiple_threads.
The synchronous-settled branches in METHOD(all)/race()/any() call
task_group_waiter_event_remove() to unlink a freshly created waiter from
the group's waiter_events[] vector while keeping its wrapper Future alive
as the method's return value. task_group_waiter_event_remove() only
cleared the vector slot; waiter->group was left pointing at the task_group.

When the coroutine later released both CVs, the task_group was freed first
(its free_obj walked an already-empty vector and never touched the waiter),
and then the Future's dispose ran waiter_future_dispose -> remove, which
dereferenced the stale group pointer.

Reset waiter->group = NULL at the end of task_group_waiter_event_remove()
so every detach — whether it originated from dispose, notify, or the sync
resolve path — leaves the waiter safe to outlive the group. Drops const
from the parameter type accordingly. Fixes the heap-use-after-free caught
by ASAN in task_group/035-task_group_all_synchronous_reject.
op_array_to_emalloc() used two independent emalloc() calls for the opcodes
and the literals table, then rebased the int32_t RT_CONSTANT offset each
opline stores. On 64-bit builds (ZEND_USE_ABS_CONST_ADDR = 0) emalloc can
place the two blocks several GB apart, which silently overflows the signed
32-bit offset and leaves the VM reading a literal from an unrelated heap
region — caught by ASan inside execute_ex while a child thread ran a
closure returning a constant.

Mirror pass_two()'s layout instead: allocate one block sized to
aligned(opcodes) + literals, place the literals immediately after the
aligned opcodes, and leave ZEND_ACC_DONE_PASS_TWO set so destroy_op_array
continues to treat the literals as embedded in the opcodes allocation
(no separate efree). Capture orig_opcodes before reassignment so the
relative-offset path computes new offsets from the stable original layout.
Fixes the heap-buffer-overflow caught by ASAN in
thread_channel/037-closure_transfer.
thread_copy_op_array_ex memcpy's opcodes including opline->handler. When
opcache JIT is enabled, those handlers are JIT stubs with the source
op_array's literal addresses and run_time_cache baked in — executing them
on a cross-thread copy crashes on a SEGV inside the JIT buffer. Call
zend_vm_set_opcode_handler() on each opline after copy so the copy runs
on the standard interpreter. Parent's SHM op_array stays JIT-compiled.

Fixes all remote_future tests under CI with opcache.enable=1 opcache.jit=tracing.
When thread_transfer_hash_table encountered an error (depth limit,
unsupported type like references/resources/dynamic properties),
it called zend_throw_error() which triggered zend_bailout() via
longjmp because there is no active EG(current_execute_data) during
C-level thread transfer. The longjmp skipped all cleanup, leaking
every intermediate pemalloc'd HashTable (up to 512 allocations).

Fix: store error message in ctx->error instead of throwing during
recursive transfer. Check ctx->error after each recursive call and
clean up partial allocations before returning. The exception is
thrown only at the top level (async_thread_transfer_zval) after
all persistent memory has been properly released.
…criber's scope

When a remote future completes via trigger callback, the callback handler
fires in the scheduler context. ZEND_ASYNC_CURRENT_SCOPE at that point is
the Scheduler scope, causing "You cannot use the Scheduler scope to create
coroutines" error.

Fix: capture scope at map()/catch()/finally() registration time in
async_future_callback_t and use it when spawning the mapper coroutine.

Also fix ASAN detection in proc_close signal test — USE_ZEND_ALLOC is not
set without --asan flag, so additionally check SKIP_ASAN env and ldd output
for libasan.
@EdmondDantes EdmondDantes merged commit 1b9786d into main Apr 16, 2026
1 check passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in True Async Board Apr 16, 2026
@EdmondDantes EdmondDantes deleted the 98-thread-pool branch April 16, 2026 14:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Thread pool

1 participant