Skip to content

Releases: true-async/php-async

v0.7.4

27 Jun 06:20
76e1e9d

Choose a tag to compare

Fixed

  • #162 ThreadChannel/spawn_thread: a worker parked on recv()/send() hung process shutdown when the owning side finished without close(). A non-awaited worker thread kept the parent scheduler alive by itself, so the parent never reached shutdown, and the worker blocked forever (no output, no diagnostic). Two changes: (1) the thread completion event is now transparent — it only keeps the parent loop alive while a coroutine actually awaits the thread — so a non-awaited worker no longer pins the parent, which finishes and proceeds to shutdown; (2) each thread now tracks the ThreadChannels it created (thread-local, in ASYNC_G) and closes them at its own shutdown, so any worker still parked on recv()/send() wakes with Async\ThreadChannelException and exits. Awaited threads still block the parent as before; works for fan-out (any number of workers).

Changed

  • phpredis async pool: connect()/pconnect() is now rejected in pool mode — configure host/port in the constructor options. A pooled Redis object is a template, not a single live connection, so connect() has no meaning there: the pool's own connections were seeding from the template socket and silently dialing 127.0.0.1, causing failed to acquire connection errors inside Docker or any environment where Redis is not on loopback. Calling connect() on a pooled instance now throws Redis::connect() is not supported in pool mode; set 'host' and 'port' in the constructor options. Use new Redis(['host' => 'redis', 'port' => 6379, 'pool' => [...]]). Consistent for both the checkout pool (mux = 0) and eager multiplex lanes (mux > 0). Fixed in phpredis redis.c/redis_pool.c; test tests/async/008-pool_connect_host.phpt.

v0.7.3

25 Jun 08:54

Choose a tag to compare

What's changed

Thread transfer — false "dynamic properties" rejection fixed

  • fix: object with declared-only properties wrongly rejected after var_dump() — the cross-thread transfer guard (spawn_thread / ThreadPool / ThreadChannel) rejected any object whose lazy properties table had been materialized (by var_dump(), get_object_vars(), foreach, array cast, ...) with Cannot transfer object with dynamic properties between threads, even when the object had no dynamic properties. The check counted every entry in zend_object.properties, but a materialized table stores declared properties as IS_INDIRECT slots into properties_table; it now rejects only genuine (non-indirect) dynamic properties.

Tests

  • tests/thread_channel/040 — sync send/recv across every materialization path (var_dump / get_object_vars / foreach / (array)), nested object, unset declared property, empty stdClass; plus genuine-dynamic, dynamic-after-var_dump, and stdClass-with-property all still rejected.
  • tests/thread_channel/041 — real cross-thread round-trip (value integrity + deep copy).
  • tests/thread_pool/069submit() of a $this-bound closure whose object had a materialized table.

Requires

  • php-src php-8.6.0-trueasync-0.7.2 — no php-src or ABI change since 0.7.2 (ABI v0.20.0); the fix is entirely in ext/async.

v0.7.2

12 Jun 13:09

Choose a tag to compare

What's changed

Thread transfer — closure scope fix

  • fix(#161): closures transferred via spawn_thread / ThreadPool / ThreadChannel now carry their class scope — a static closure declared inside a class arrived in the worker without a scope, so self::/static:: threw Cannot access "self" when no class scope is active; a $this-bound closure resolved Z_OBJCE($this) instead of its declaring class, breaking self:: and private-member visibility under inheritance. The snapshot now carries scope/called_scope by name and re-resolves them in the target thread. A missing class throws Cannot restore closure scope: class "X" not found in the target thread. Closures scoped to anonymous classes are rejected at transfer time.

ext/curl — libcurl 8.20.x DNS hang fix

  • fix(curl): async DNS hang on libcurl 8.20.x — libcurl 8.20.0 moved to a thread-pool DNS resolver whose completion socketpair is not surfaced via CURLMOPT_SOCKETFUNCTION, so the async curl integration (which drives the multi handle purely through curl_multi_socket_action()) never received the resolve result — running_handles stayed pinned and DNS timed out with CURLE_OPERATION_TIMEDOUT. Fixed by calling curl_multi_perform() in both timer callbacks to drain resolver results. The workaround is #if-scoped to >= 8.20.0 && < 8.21.0 only — it compiles out on 8.19 and earlier (native socketpair path) and on 8.21+, where libcurl fixes this upstream (curl/curl#21476).

Requires

  • php-src php-8.6.0-trueasync-0.7.2 (libcurl DNS fix in ext/curl)

v0.7.1

09 Jun 04:53

Choose a tag to compare

What's changed

ThreadPool — memory safety & correctness fixes

  • fix(threadpool): per-task nursery scope for sync tasks — snapshot UAF fixed: sync task body now runs in its own nursery Scope; spawned coroutines are drained before the snapshot arena is freed (Windows debug-heap crash / ASAN-caught on Linux)
  • fix(threadpool): deliver fatal in sync task body to awaiter — a fatal (OOM/exit) no longer hangs the awaiter; future is rejected with ThreadTransferException
  • fix(threadpool): fatal cause delivery — coroutine-mode tasks no longer silently resolve to null on a fatal; cause is built on the healthy parent side from a pestrdup'd message (not from the dying worker's allocator)
  • fix(threadpool): snapshot UAF + libuv loop leak on fatal — op_array name strings materialized into refcounted heap strings; uv_async handles (ThreadChannel/slot_event) now properly disposed on bailout through SUSPEND

Windows fixes

  • fix(win): sendfile to socket via TransmitFileuv_fs_sendfile to a TCP socket was broken on Windows (Winsock SOCKET ≠ CRT fd); replaced with TransmitFile

Cross-thread memory safety

  • fix: closure captured-variable names UAF in spawn_thread — interned string keys from parent's table were stored in worker snapshot; freed when parent ended; now copied into private persistent strings

Test stabilisation

  • Stabilised two race-flaky tests surfaced by CI on Windows (063-bootloader_exception, 034-stdio_fopen_fwrite)

Requires

  • php-src ABI v0.20.0 (zend_async_scope_await_after_cancellation_fn) — ship with php-8.6.0-trueasync-0.7.1

v0.7.0 — ThreadPool, request scope, stability hardening

02 Jun 15:38

Choose a tag to compare

First stable release since v0.6.7 — a large capability + hardening release that folds in the entire 0.7.0 alpha/beta/rc cycle. Headlines: a real OS-thread ThreadPool (per-worker bootloaders, coroutine-mode tasks, thread channels), request-level scope (Async\request_context()), opt-in PDO prepared-statement pooling (~2.9×), three-layer channel/pool deadlock protection, and a deep stability pass driven by a new randomized chaos test suite (now 100% public-API coverage). ABI bumped to v0.19.0.

This runtime powers the high-performance HTTP/1.1 · HTTP/2 · HTTP/3 application server true-async/server, released in lockstep as v0.7.2.

⚠️ Breaking changes

  • new Scope() now defaults to Not-Safe disposal. A fresh root Scope no longer sets DISPOSE_SAFELY: dispose() cancels its coroutines synchronously instead of leaving zombies. Main scope and Scope::inherit(...) chains are unchanged. Migration: (new Scope())->allowZombies() to keep the old behavior.
  • For API/reactor consumers: ABI 0.15 → 0.19 (unified thread-pool factory; zend_async_io_register gains sendfile_fn/fs_open_fn; io_closed field on IO/UDP reqs); TaskGroup/TaskSet seal()close(), isSealed()isClosed(); cross-thread transfer now rejects closures that declare classes/functions (file:line).

Added — multithreading

  • Async\ThreadPool — pool of OS threads for PHP closures: submit()/map()/close() (graceful)/cancel() (rejects backlog), counters, Countable; ThreadPoolException when closed.
  • Workers auto-detect (workers: 0available_parallelism()), per-worker bootloader hook, and coroutine: true mode (each task is a coroutine in its own child scope — may await/use channels/IO). cancel() in coroutine mode actually kills in-flight tasks.
  • Async\ThreadChannel — thread-safe channel via deep-copy snapshot; send/recv suspend the coroutine, not the OS thread (ThreadChannelException).
  • C-only ThreadPool::submit_internal; cross-thread top-level zval transfer helpers.

Added — concurrency & pooling

  • Request-level scopeAsync\request_context(): ?Context, O(1) ZEND_ASYNC_REQUEST_SCOPE (#105). This is what the true-async/server HttpServerConfig::setRequestScope() knob builds on.
  • Channel deadlock protection (3 layers): per-channel noProducerTimeout/noConsumerTimeout, global soft-timer resolver, owner-scope auto-close; typed Async\ChannelCloseReason.
  • PDO Pool prepared-statement cachePDO::ATTR_POOL_STMT_CACHE_SIZE (pgsql/mysql/sqlite), per-conn LRU with transparent plan-invalidation retry; ~2.9× on a tight prepare+execute+fetch loop.
  • PDO_SQLite connection pool (PDO::ATTR_POOL_ENABLED).
  • TaskGroup/TaskSet queueLimit backpressure (default 2 × concurrency).
  • Timer rearm / multishot API.

Added — I/O & introspection

  • Async sendfile (uv_fs_sendfile) and async open(2).
  • Async\available_parallelism() (respects cgroup quota/affinity); CPU probesCpuSnapshot::now(), cpu_usage(), loadavg() (ZTS-safe; null on Windows where N/A).

Changed

  • TaskGroup/TaskSet seal()close() terminology.
  • fuzzy_tests/fuzzy-tests/.

Performance

  • +32% RPS on a minimal HTTP handler — static TSRMLS cache (ZEND_ENABLE_STATIC_TSRMLS_CACHE) turns every EG()/ASYNC_G() into a single __thread load.

Fixed

Deep stability pass (full per-bug detail in CHANGELOG.md):

  • Thread pool / cross-thread transfer — worker crash on exit()/die() in task/bootloader (#154), bootloader-error swallowing (#154), cross-thread task UAF under cancel-vs-blocked-worker (#146), $this-bound closure SEGV, enum singleton identity, self-referential object cycles, type-info deep-copy (~12k→175k req/s under load).
  • Reactor / I/O — close-mid-read hangs parked reader (#144), concurrent writes to one file/descriptor losing data or corrupting heap on macOS/Windows (#129), writer not waking on peer reset, double-stop event underflow, Windows TCP accept.
  • Channels / poolsChannel(0) close-vs-send split-brain (#127) and missing rendezvous (#108), pool deadlock on broken-release (#141), recvAsync()/pool GC-cycle leaks.
  • curlcurl_multi_select cancel UAF (#145), async-read dangling subscription, callback exception leaks (#118).
  • JIT — tracing-JIT stale-FP spill SEGV in chaos fuzz (#118).
  • Leaks — OpenSSL 3 per-thread RCU state, scope/timer/signal/composite-exception refcounts, OOM-bailout double-warning.
  • Late await() double-throw on a coroutine that finished with an exception (#139); Async\iterate refcount UAF (#143); BSD/Darwin signal enum values; Async\signal() clobbering worker threads (#109).

Testing & CI

  • New randomized chaos suite (fuzzy-tests/): EvilPeer + Toxiproxy transport fault injection (#127/#129), mutation-block scenarios, 100% public-API coverage (167/167). Runs under TRUE_ASYNC_SCHED=random:{1,7,42,1337} per-PR on top of the deterministic FIFO run.

Ecosystem

  • true-async/server — async HTTP/1.1 · HTTP/2 · HTTP/3 server built on this runtime (v0.7.2).
  • Coordinated TrueAsync 0.7.0 release also tags php-src, phpredis, xdebug, and frankenphp; Docker images are published from true-async/releases.

v0.6.7

14 Apr 14:12

Choose a tag to compare

TrueAsync PHP 0.6.7 (php 8.6)

Highlights

  • FrankenPHP on Windows — both Release and Debug builds of FrankenPHP are now produced and shipped alongside the regular PHP artifacts.
  • Installer: optional FrankenPHP install — the Windows PowerShell installer now asks (or reads INSTALL_FRANKENPHP=true) whether to also install FrankenPHP next to php.exe.

Async extension

Added

  • PDO Pool: getAttribute() support for pool attributes. $pdo->getAttribute(PDO::ATTR_POOL_ENABLED) now returns true/false depending on whether the connection pool is active. PDO::ATTR_POOL_MIN and PDO::ATTR_POOL_MAX return the configured pool size limits (or false when pooling is disabled). PDO::ATTR_POOL_HEALTHCHECK_INTERVAL is a construction-only attribute and raises an error if read at runtime.

Fixed

  • Heap-use-after-free in await_all() / await_*() with string keys. When any await_* function received an array with non-interned string keys (e.g. from json_decode() or str_repeat()), the returned results/errors arrays had incorrect refcount on those keys. Root cause: async_waiting_callback_dispose was called twice per callback (once from zend_async_callbacks_remove during del_callback, once from ZEND_ASYNC_EVENT_CALLBACK_RELEASE), but did not check ref_count — it unconditionally called zval_ptr_dtor on the key each time, decrementing the string refcount twice instead of once. When the calling function's local variables were freed (i_free_compiled_variables), the already-freed string was accessed again. Fixed by adding a ref_count guard to async_waiting_callback_dispose: when ref_count > 1, decrement and return without touching resources; cleanup happens only on the final dispose (ref_count == 1).

Windows build

Added

  • FrankenPHP support for Windows (Release and Debug). Both build types now produce a slim *-frankenphp.zip addon archive containing only FrankenPHP-specific files: frankenphp.exe, libwatcher-c.dll, brotli*.dll and pthreadVC3.dll. The PHP runtime DLL and extensions are not duplicated — extract this archive on top of the main PHP package.
  • Debug FrankenPHP linkage. FrankenPHP's cgo configuration was split into cgo_windows.go / cgo_windows_debug.go gated by the zend_debug Go build tag. When building against a debug devel pack, the workflow passes -tags zend_debug so clang sees -DZEND_DEBUG=1, matching the signatures of _emalloc/_efree/_estrdup in php8ts_debug.lib (which gain ZEND_FILE_LINE_DC arguments in debug mode).
  • Installer: optional FrankenPHP install. installer/install.ps1 now exposes an INSTALL_FRANKENPHP environment variable and an interactive "Install FrankenPHP?" prompt. When enabled, the installer downloads the matching slim frankenphp addon archive (respecting the Release/Debug choice), verifies its checksum against the same sha256sums.txt, and extracts it on top of the main install — frankenphp.exe lands next to php.exe.

Fixed

  • Clang/MSVC ABI mismatch in frankenphp_extension.c. Calls to emalloc(sizeof(zval)) were routed through zend_alloc.h's __builtin_constant_p specialization to _emalloc_16, which MSVC-built php8ts.lib does not export. Replaced with safe_emalloc(1, sizeof(zval), 0) to hit the exported _safe_emalloc entry point.
  • strtok_r unresolved on Windows. The Windows CRT has no strtok_r. Replaced all four call sites in frankenphp_extension.c with php_strtok_r (portable wrapper from main/php_reentrancy.h).
  • Debug packaging hardcoded release DLL names. The Package FrankenPHP step tried to copy php8ts.dll, but debug builds ship php8ts_debug.dll (see win32/build/confutils.js PHPLIB). Packaging now globs php*ts*.dll and php*ts*.lib so both release and debug names work.

Installation

Windows (PowerShell)

irm https://raw.githubusercontent.com/true-async/releases/master/installer/install.ps1 | iex

To also install FrankenPHP non-interactively:

$env:INSTALL_FRANKENPHP="true"; irm https://raw.githubusercontent.com/true-async/releases/master/installer/install.ps1 | iex

Docker

docker pull trueasync/php-true-async:0.6.7-php8.6
docker pull trueasync/php-true-async:latest

v0.6.6

03 Apr 10:44

Choose a tag to compare

What's Changed

  • libuv bumped to 1.52.1 — versions below 1.52.1 have known IO-URING issues on Linux; all UNIX/Linux builds and Docker images now ship with the fixed version.
  • OPcache production config in Docker images — both php-true-async (Debian) and FrankenPHP images now ship with a tuned opcache.ini out of the box: JIT tracing mode, validate_timestamps=0 (files don't change in containers), 256 MB bytecode cache, 128 MB JIT buffer.

Performance Benchmarks

We ran a realistic benchmark against Laravel + PostgreSQL (10 SQL queries per request, 1000 req/s constant load, 30s duration) comparing TrueAsync against Swoole NTS, Swoole ZTS, and FrankenPHP Octane.

Throughput (16 workers)

Server req/s vs TrueAsync Dropped requests
TrueAsync 993 req/s 1.1%
Swoole NTS 599 req/s TrueAsync +66% ~38%
Swoole ZTS 601 req/s TrueAsync +65% ~36%
FrankenPHP Octane 556 req/s TrueAsync +79% ~41%

TrueAsync handles the full target load with just 4 workers. Blocking servers need ~25 workers to reach the same throughput — a 6× worker efficiency advantage.

Latency at 4 workers

Server P50 P95
TrueAsync 28 ms 60 ms
Swoole NTS 5,440 ms 5,630 ms
Swoole ZTS 5,320 ms 5,520 ms
FrankenPHP Octane 5,240 ms 5,390 ms

The entire difference is queue wait: with 4 blocking workers at 1000 req/s, requests sit in queue for ~5,400 ms. TrueAsync coroutines handle requests immediately — queue wait is ~0 ms.

Memory footprint (idle)

Server 4 workers 16 workers
TrueAsync 147 MB 326 MB
Swoole NTS 481 MB (+227%, 3.3×) 762 MB (+134%, 2.3×)
Swoole ZTS 512 MB (+248%, 3.5×) 765 MB (+135%, 2.3×)
FrankenPHP Octane 357 MB (+143%, 2.4×) 421 MB (+29%, 1.3×)

Each Swoole worker bootstraps its own full copy of the Laravel application (~22 MB/worker). TrueAsync coroutines share the bootstrap within a single worker — ~2.5 MB per coroutine, ~89% less than Swoole (9× lighter).

Full results and charts: https://github.com/YanGusik/ta_benchmark/blob/main/RESULTS.md


Installation

Docker (fastest)

# Standard PHP CLI / FPM image
docker run --rm trueasync/php-true-async:latest php -r "var_dump(extension_loaded('true_async'));"

# FrankenPHP async server
docker run --rm -p 8080:8080 trueasync/php-true-async:latest-frankenphp

Linux (Ubuntu / Debian)

curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-linux.sh | bash

macOS

curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-macos.sh | bash

Windows

Download the pre-built ZIP from the releases page.


Verify installation

var_dump(extension_loaded('true_async')); // bool(true)
var_dump(ZEND_THREAD_SAFE);               // bool(true)

Full Changelog: v0.6.5...v0.6.6

v0.6.5

29 Mar 08:07

Choose a tag to compare

What's Changed

FrankenPHP Async Worker

v0.6.5 ships with full support for FrankenPHP in async worker mode — a single PHP thread now handles many concurrent requests, each running as a coroutine. While one coroutine is waiting for I/O (database query, HTTP call, file read), the scheduler runs other coroutines on the same thread.

Traditional FPM / standard FrankenPHP:
  1 request → 1 thread  (blocked during I/O)

TrueAsync FrankenPHP:
  N requests → 1 thread  (coroutines, non-blocking I/O)

Performance

  • Waker inline storage optimization: Embedded 2 trigger slots and 2 callback slots directly into the Waker struct, eliminating heap allocations for the most common case (1–2 events per await). Benchmarks: await 2.13 → 0.67 μs (~3×), await_all ×2 3.88 → 1.38 μs (~3×), Channel send/recv 1.48 → 0.50 μs (~3×).
  • Adaptive fiber pool sizing: The fiber context pool now grows dynamically based on coroutine queue pressure instead of a fixed pool size of 4. Yields 10–15% improvement in context switch throughput (10k coroutines × 10 suspends: 490 → 566 switches/ms).

Special thanks to YanGus for helping the project!

Async Laravel is in progress

https://github.com/YanGusik/laravel-spawn/
Right now, Laravel is being adapted for TrueAsync,
which, according to preliminary tests, can deliver a 3x or greater improvement in I/O performance!
And they said this technology wasn’t needed by anyone :)

Changed

  • ZEND_ASYNC_SUSPEND no longer throws an error when called with an empty array of events.

Fixed

  • SIGSEGV in pool healthcheck callback: Corrupted pool event structure fields caused a segfault when the pool was closed. Fixed by embedding a proper zend_async_event_callback_t inside async_pool_t.
  • proc_close() crash when child process already reaped: Handled ECHILD in async_wait_process() and libuv_process_event_start().
  • Pool acquire with failed factory caused use-after-free: Fixed by checking EG(exception) after factory failure and returning immediately.
  • Missing exception checks in pool error paths: Added EG(exception) checks in healthcheck loop and other error paths.
  • Pool close() now chains destructor exceptions via previous: All resources are destroyed and exceptions are chained via zend_exception_set_previous() instead of being silently discarded.
  • Pool destructor exceptions now propagate: Removed silent suppression via zend_clear_exception().

Installation

Docker (fastest)

# Standard PHP CLI / FPM image
docker run --rm trueasync/php-true-async:latest php -r "var_dump(extension_loaded('true_async'));"

# FrankenPHP async server
docker run --rm -p 8080:8080 trueasync/php-true-async:latest-frankenphp

Linux (Ubuntu / Debian)

curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-linux.sh | bash

To include FrankenPHP:

curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-linux.sh | \
  BUILD_FRANKENPHP=true NO_INTERACTIVE=true bash

macOS

curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-macos.sh | bash

Windows

Download the pre-built ZIP from the releases page.


Verify installation

var_dump(extension_loaded('true_async')); // bool(true)
var_dump(ZEND_THREAD_SAFE);               // bool(true)

Full Changelog: v0.6.4...v0.6.5

v0.6.3

25 Mar 12:38

Choose a tag to compare

What's Changed

Fixed

  • Scope::awaitCompletion() deadlock: async_scope_notify_coroutine_finished() was not calling scope_check_completion_and_notify(), so awaitCompletion() always waited the full timeout even after all coroutines had finished. Also fixed awaitAfterCancellation to use ZEND_ASYNC_WAKER_DESTROY and correctly check zend_async_resume_when return value.

  • Scope dispose use-after-free: scope_dispose now keeps ref_count=1 as a guard during disposal and drops it only before efree. Removes a premature DEL_REF that caused use-after-free when finally handlers created child scopes. finally_handlers_iterator_dtor now uses ZEND_ASYNC_SCOPE_RELEASE to avoid double-decrement.

  • Poll event leak on negative stream timeout: In network_async.c, a negative tv_sec caused a poll event refcount leak. Fixed by guarding against negative timeout values.

Full Changelog: v0.6.2...v0.6.3

v0.6.2

24 Mar 14:00

Choose a tag to compare

What's Changed

Added

  • Non-blocking flock(): flock() no longer blocks the event loop. The lock operation is offloaded to the libuv thread pool via zend_async_task_t, allowing other coroutines to continue executing while waiting for a file lock.
  • zend_async_task_new() API: New factory function for creating thread pool tasks, registered through the reactor like timer and IO events. Replaces manual pecalloc + field initialization.

Fixed

  • await_*() deadlock with already-completed awaitables: When a coroutine or Future passed to await_all(), await_any_or_fail(), or other await_*() functions had already completed, it was skipped entirely (ZEND_ASYNC_EVENT_IS_CLOSEDcontinue), but resolved_count was never incremented. Since total still counted the skipped awaitable, resolved_count could never reach total, causing a deadlock. Fixed by using ZEND_ASYNC_EVENT_REPLAY to synchronously replay the stored result/exception through the normal callback path, correctly updating all counters. Additionally, when replay satisfies the waiting condition early (e.g. await_any_or_fail needs only one result), the loop now breaks immediately instead of subscribing to remaining awaitables and suspending unnecessarily.

Full Changelog: v0.6.1...v0.6.2