Releases: true-async/php-async
v0.7.4
Fixed
- #162
ThreadChannel/spawn_thread: a worker parked onrecv()/send()hung process shutdown when the owning side finished withoutclose(). 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 actuallyawaits the thread — so a non-awaited worker no longer pins the parent, which finishes and proceeds to shutdown; (2) each thread now tracks theThreadChannels it created (thread-local, inASYNC_G) and closes them at its own shutdown, so any worker still parked onrecv()/send()wakes withAsync\ThreadChannelExceptionand 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 — configurehost/portin the constructor options. A pooledRedisobject is a template, not a single live connection, soconnect()has no meaning there: the pool's own connections were seeding from the template socket and silently dialing127.0.0.1, causingfailed to acquire connectionerrors inside Docker or any environment where Redis is not on loopback. Callingconnect()on a pooled instance now throwsRedis::connect() is not supported in pool mode; set 'host' and 'port' in the constructor options. Usenew Redis(['host' => 'redis', 'port' => 6379, 'pool' => [...]]). Consistent for both the checkout pool (mux = 0) and eager multiplex lanes (mux > 0). Fixed in phpredisredis.c/redis_pool.c; testtests/async/008-pool_connect_host.phpt.
v0.7.3
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 (byvar_dump(),get_object_vars(),foreach, array cast, ...) withCannot transfer object with dynamic properties between threads, even when the object had no dynamic properties. The check counted every entry inzend_object.properties, but a materialized table stores declared properties asIS_INDIRECTslots intoproperties_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,unsetdeclared property, emptystdClass; plus genuine-dynamic, dynamic-after-var_dump, andstdClass-with-property all still rejected.tests/thread_channel/041— real cross-thread round-trip (value integrity + deep copy).tests/thread_pool/069—submit()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 inext/async.
v0.7.2
What's changed
Thread transfer — closure scope fix
- fix(#161): closures transferred via
spawn_thread/ThreadPool/ThreadChannelnow carry their class scope — astaticclosure declared inside a class arrived in the worker without a scope, soself::/static::threwCannot access "self" when no class scope is active; a$this-bound closure resolvedZ_OBJCE($this)instead of its declaring class, breakingself::and private-member visibility under inheritance. The snapshot now carriesscope/called_scopeby name and re-resolves them in the target thread. A missing class throwsCannot 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 throughcurl_multi_socket_action()) never received the resolve result —running_handlesstayed pinned and DNS timed out withCURLE_OPERATION_TIMEDOUT. Fixed by callingcurl_multi_perform()in both timer callbacks to drain resolver results. The workaround is#if-scoped to>= 8.20.0 && < 8.21.0only — 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 inext/curl)
v0.7.1
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_asynchandles (ThreadChannel/slot_event) now properly disposed on bailout throughSUSPEND
Windows fixes
- fix(win): sendfile to socket via
TransmitFile—uv_fs_sendfileto a TCP socket was broken on Windows (Winsock SOCKET ≠ CRT fd); replaced withTransmitFile
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 withphp-8.6.0-trueasync-0.7.1
v0.7.0 — ThreadPool, request scope, stability hardening
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 rootScopeno longer setsDISPOSE_SAFELY:dispose()cancels its coroutines synchronously instead of leaving zombies. Main scope andScope::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_registergainssendfile_fn/fs_open_fn;io_closedfield on IO/UDP reqs);TaskGroup/TaskSetseal()→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;ThreadPoolExceptionwhen closed.- Workers auto-detect (
workers: 0→available_parallelism()), per-workerbootloaderhook, andcoroutine: truemode (each task is a coroutine in its own child scope — mayawait/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 scope —
Async\request_context(): ?Context, O(1)ZEND_ASYNC_REQUEST_SCOPE(#105). This is what the true-async/serverHttpServerConfig::setRequestScope()knob builds on. - Channel deadlock protection (3 layers): per-channel
noProducerTimeout/noConsumerTimeout, global soft-timer resolver, owner-scope auto-close; typedAsync\ChannelCloseReason. - PDO Pool prepared-statement cache —
PDO::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/TaskSetqueueLimitbackpressure (default2 × concurrency).- Timer rearm / multishot API.
Added — I/O & introspection
- Async
sendfile(uv_fs_sendfile) and asyncopen(2). Async\available_parallelism()(respects cgroup quota/affinity); CPU probes —CpuSnapshot::now(),cpu_usage(),loadavg()(ZTS-safe;nullon Windows where N/A).
Changed
TaskGroup/TaskSetseal()→close()terminology.fuzzy_tests/→fuzzy-tests/.
Performance
- +32% RPS on a minimal HTTP handler — static TSRMLS cache (
ZEND_ENABLE_STATIC_TSRMLS_CACHE) turns everyEG()/ASYNC_G()into a single__threadload.
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 / pools —
Channel(0)close-vs-send split-brain (#127) and missing rendezvous (#108), pool deadlock on broken-release (#141),recvAsync()/pool GC-cycle leaks. - curl —
curl_multi_selectcancel 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\iteraterefcount 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 underTRUE_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, andfrankenphp; Docker images are published from true-async/releases.
v0.6.7
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 tophp.exe.
Async extension
Added
- PDO Pool:
getAttribute()support for pool attributes.$pdo->getAttribute(PDO::ATTR_POOL_ENABLED)now returnstrue/falsedepending on whether the connection pool is active.PDO::ATTR_POOL_MINandPDO::ATTR_POOL_MAXreturn the configured pool size limits (orfalsewhen pooling is disabled).PDO::ATTR_POOL_HEALTHCHECK_INTERVALis 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 anyawait_*function received an array with non-interned string keys (e.g. fromjson_decode()orstr_repeat()), the returned results/errors arrays had incorrect refcount on those keys. Root cause:async_waiting_callback_disposewas called twice per callback (once fromzend_async_callbacks_removeduringdel_callback, once fromZEND_ASYNC_EVENT_CALLBACK_RELEASE), but did not checkref_count— it unconditionally calledzval_ptr_dtoron 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 aref_countguard toasync_waiting_callback_dispose: whenref_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.zipaddon archive containing only FrankenPHP-specific files:frankenphp.exe,libwatcher-c.dll,brotli*.dllandpthreadVC3.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.gogated by thezend_debugGo build tag. When building against a debug devel pack, the workflow passes-tags zend_debugso clang sees-DZEND_DEBUG=1, matching the signatures of_emalloc/_efree/_estrdupinphp8ts_debug.lib(which gainZEND_FILE_LINE_DCarguments in debug mode). - Installer: optional FrankenPHP install.
installer/install.ps1now exposes anINSTALL_FRANKENPHPenvironment 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 samesha256sums.txt, and extracts it on top of the main install —frankenphp.exelands next tophp.exe.
Fixed
- Clang/MSVC ABI mismatch in
frankenphp_extension.c. Calls toemalloc(sizeof(zval))were routed throughzend_alloc.h's__builtin_constant_pspecialization to_emalloc_16, which MSVC-builtphp8ts.libdoes not export. Replaced withsafe_emalloc(1, sizeof(zval), 0)to hit the exported_safe_emallocentry point. strtok_runresolved on Windows. The Windows CRT has nostrtok_r. Replaced all four call sites infrankenphp_extension.cwithphp_strtok_r(portable wrapper frommain/php_reentrancy.h).- Debug packaging hardcoded release DLL names. The Package FrankenPHP step tried to copy
php8ts.dll, but debug builds shipphp8ts_debug.dll(seewin32/build/confutils.jsPHPLIB). Packaging now globsphp*ts*.dllandphp*ts*.libso both release and debug names work.
Installation
Windows (PowerShell)
irm https://raw.githubusercontent.com/true-async/releases/master/installer/install.ps1 | iexTo also install FrankenPHP non-interactively:
$env:INSTALL_FRANKENPHP="true"; irm https://raw.githubusercontent.com/true-async/releases/master/installer/install.ps1 | iexDocker
docker pull trueasync/php-true-async:0.6.7-php8.6
docker pull trueasync/php-true-async:latestv0.6.6
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 tunedopcache.iniout 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-frankenphpLinux (Ubuntu / Debian)
curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-linux.sh | bashmacOS
curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-macos.sh | bashWindows
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
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
Wakerstruct, eliminating heap allocations for the most common case (1–2 events per await). Benchmarks:await2.13 → 0.67 μs (~3×),await_all×2 3.88 → 1.38 μs (~3×),Channelsend/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_SUSPENDno 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_tinsideasync_pool_t. proc_close()crash when child process already reaped: HandledECHILDinasync_wait_process()andlibuv_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 viaprevious: All resources are destroyed and exceptions are chained viazend_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-frankenphpLinux (Ubuntu / Debian)
curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-linux.sh | bashTo include FrankenPHP:
curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-linux.sh | \
BUILD_FRANKENPHP=true NO_INTERACTIVE=true bashmacOS
curl -fsSL https://raw.githubusercontent.com/true-async/releases/master/installer/build-macos.sh | bashWindows
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
What's Changed
Fixed
-
Scope::awaitCompletion()deadlock:async_scope_notify_coroutine_finished()was not callingscope_check_completion_and_notify(), soawaitCompletion()always waited the full timeout even after all coroutines had finished. Also fixedawaitAfterCancellationto useZEND_ASYNC_WAKER_DESTROYand correctly checkzend_async_resume_whenreturn value. -
Scope dispose use-after-free:
scope_disposenow keepsref_count=1as a guard during disposal and drops it only beforeefree. Removes a prematureDEL_REFthat caused use-after-free whenfinallyhandlers created child scopes.finally_handlers_iterator_dtornow usesZEND_ASYNC_SCOPE_RELEASEto avoid double-decrement. -
Poll event leak on negative stream timeout: In
network_async.c, a negativetv_seccaused a poll event refcount leak. Fixed by guarding against negative timeout values.
Full Changelog: v0.6.2...v0.6.3
v0.6.2
What's Changed
Added
- Non-blocking
flock():flock()no longer blocks the event loop. The lock operation is offloaded to the libuv thread pool viazend_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 manualpecalloc+ field initialization.
Fixed
await_*()deadlock with already-completed awaitables: When a coroutine or Future passed toawait_all(),await_any_or_fail(), or otherawait_*()functions had already completed, it was skipped entirely (ZEND_ASYNC_EVENT_IS_CLOSED→continue), butresolved_countwas never incremented. Sincetotalstill counted the skipped awaitable,resolved_countcould never reachtotal, causing a deadlock. Fixed by usingZEND_ASYNC_EVENT_REPLAYto 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_failneeds 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