Async Analysis 2026 10 03 - crazy-goat/php-fpm-ng GitHub Wiki
php-fpm-ng async branch analysis (2026-10-03)
Subject: origin/async at d7a29fd, compared with origin/main at 2dacf9a.
Method: six area audits (fiber core, I/O interceptions and patches, coop state isolation, sessions, branch infrastructure, tests/docs), plus a framework-readiness audit. Each audit was checked by an independent, adversarial verifier. This report uses only findings that were confirmed or partly confirmed; partly confirmed findings are stated in their corrected form. Refuted parts are left out.
Conventions:
- C paths without a directory are in
sapi/fpmng/fpm/on the async tree. - php-src references are to php-src 8.5.9.
- "Verified" means checked by reading code, or by a read-only git/gh command.
- "Inference" means it follows from the code but was not run.
- Almost nothing was compiled or run. Only three things were executed:
vendor-php-src.sh checkon the merged tree,test-prepare-patch-stack.shon the test box, andbuild/test-fiber-io-seam.shlocally. The rest comes from reading.
1. TL;DR
- The goal is reachable only in part, and only for a narrow class of apps. Symfony 8.1.6, Laravel 13.30.1 and Slim 4.15.3 were each made to pass the repo's own harness, but only with app cooperation:
env[FPMNG_SHARED_INCLUDES]=1, a hand-written or editedindex.php, and a hand-curatedfiber.isolate_staticslist (docs/frameworks.md:8,40-60,604-640). Those results are stale. They predate #379 and the #531 seam refactor, Symfony was measured on PHP 8.6.0-dev on macOS (docs/frameworks.md:488-492), and no CI job runs the harness. - The php-src patch cost is small. The real cost is in the SAPI and in the engine's model. Async needs only 0007 (466 lines, ext/openssl) and 0008 (74 lines, stream_select). io_hooks (#599/#600) could replace both. The hard problems are elsewhere: one shared heap, one object store and one
memory_limitper process; OPcache refused (fpm_pool_coop.c:248-256); no per-request time limit. - The core switching design is sound and worth keeping. The SG/OG/symbol-table/handler/session/ini swap (fpm_pool_coop.c:590-666), the fastcgi executor-variant plumbing, the I/O seam and registry (fpm_pool_fiber_intercept.c:56-62), and the session globals swap all hold up.
- The failure lifecycle is not safe. This is the most urgent code work. Several defects can stall or corrupt a whole child:
- A fatal error in one request poisons the whole child:
gc_protect(1),CG(unclean_shutdown)=1, and every live object of every in-flight request marked destructed (php-src main/main.c:1528-1540, Zend/zend.c:1263-1264; nothing in sapi/fpmng resets it). - SIGQUIT abandons in-flight requests (fpm_pool_fiber.c:590-596).
- A dropped request leaks
in_flight, its ctx and any session lock, so a revalidate drain hangs (fpm_pool_fiber.c:150-171, fpm_pool_coop.c:962). register_shutdown_functioncallbacks never run, and the list grows for the whole life of the process.
- A fatal error in one request poisons the whole child:
- Several I/O paths still block the whole process, though the docs claim otherwise.
- ssl://, tls:// and https:// connect and their DNS lookup block (php-src xp_ssl.c:2530-2547).
- TLS reads can stall because the read wrapper ignores
SSL_pending(fpm_pool_fiber_xport.c:195-213). - The FastCGI header read, the POST read and the response write are blocking (fpm_pool_coop.c:343-368, 491-519).
- curl and pdo_pgsql are not intercepted (#81).
- Lock handling can deadlock.
- The flock registry keeps a holder after its fd is closed without
LOCK_UN, and it never frees entries. - Cross-process flock contention falls back to a blocking
flock(2). Both this and the sessionfileshandler can freeze two workers permanently (inference). - The session-lock arbiter has a use-after-free (fpm_pool_coop_session_patch.c:221-271) and self-deadlocks on
session_reset()(:274-281).
- The flock registry keeps a holder after its fd is closed without
- There is dead or old code to remove:
- the True Async executor
fpm_pool_async.c(577 lines; validate always returns -1, :71-80). Deleting it needs Piotr's decision, see #530; fpm_pool_coop_session_lock.c.notbuiltand its orphan.h;fpm_pool_fiber_validate()andfpm_coop_reval_enabled();- Polish log strings, and many stale docs and line references.
- the True Async executor
- The branch cannot sync on its own. It is 13 commits ahead and 27 behind (merge base fe608da).
git merge-treereports 7 conflicting files.async-sync.ymlhas never run. Main's no-php-src contract breaks async after a merge:- the patch fingerprint check fails (this was run: rc=1);
- main's SDK source glob would pick up the unguarded fiber/coop/async sources;
- async's CI depends on a retired job, script and image. A manual merge plus a structural re-base is needed now.
- Tests miss the dangerous paths. No phpt covers a fatal error, reload with a request in flight, drain/revalidate, shutdown functions,
isolate_statics,FPMNG_SHARED_INCLUDES, same-session-id contention, or flock close withoutLOCK_UN. Several existing tests are vacuous or race-prone. - Verdict. As a research vehicle, async has answered a lot. "Big frameworks can run as fibers with minimal php-src patches" is true for the I/O part. It is not yet true for production use. The open blockers are OPcache (php-src need not yet proven, see section 8), fatal-error isolation and per-request memory/time limits (these need php-src, or a recycle-on-fatal policy), and the silent-wrong-data class of bugs that comes from the deny-list isolation model (#50, #79, function statics). Before more features: harden the lifecycle, re-base the branch on main's contract, delete the dead code, and re-measure the frameworks in CI.
2. Branch state
| Item | Value | Evidence |
|---|---|---|
| Ahead / behind main | 13 / 27 | git rev-list --left-right --count origin/main...origin/async → 27 13 (verified) |
| Merge base | fe608da (2026-09-30, v0.11.1 line) | git merge-base (verified) |
| Previous syncs | Both done by hand: b5b8929 (2026-09-18), 365228c / PR #538 (2026-10-01) | git log (verified) |
Conflicts in merge-tree |
7 files: .github/workflows/async-sync.yml (add/add), build-matrix.yml (6 blocks), README.md, build/ci-build-tree.sh (modify/delete), build/ci-package-gate.sh (3), build/libphp-build.sh (2), docs/fpmng-phpt.md |
git merge-tree --write-tree → 382e163 (verified). No sapi/fpmng C file conflicts. |
| Auto-merged shared files | fpm_conf.c/h, fpm_http.c, fpm_http_direct_worker.c, fpm_pool_type.c/.h, config.m4, prepare.sh, patches/README.md, sapi/fpmng/README.md | merge-tree (verified). The append-at-the-end rule (#371) works. |
| Paths the merge deletes from async | 8 (ci.Dockerfile, static-full.sh, test-static-full.sh, gh-release-create.sh, 3 issue templates, workflow.md) | git diff --diff-filter=D origin/async 382e163 (verified) |
| Post-merge vendor check | FAIL: patches/ changed since the last import (manifest fc9341c6…, now b8e2c982…) |
build/vendor-php-src.sh check on 382e163 (run, rc=1); main vendor-php-src.sh:82-104 fingerprints every patches/*.patch |
| Post-merge SDK build | main's sources() (libphp-build.sh:345-353) uses find fpm -name '*.c' and skips only fpm_tls_/fpm_acme_. The fiber/coop/async .c files have no top-level guard; only fpm_pool_type_coop.c:19 and fpm_pool_fiber_xport.c:753 are partly guarded. |
Verified by reading. Whether this fails to compile or silently links dead code is not verified (silently linked dead code is more likely, by inference). |
| Async CI | The whole async build-matrix.yml is still the pre-#424 php-src-tree pipeline: checks at :245/:321 and the patches job at :359, build at :392-447, static-musl at :828-858; the fiber cell at :677-681 needs: patches and runs ci-build-tree.sh at :734. All of it runs on ghcr.io/crazy-goat/php-fpm-ng-ci:latest, frozen at fe608da. Main retired this image (main ci-image.yml:3-6). |
Verified |
| async-sync automation | async-sync.yml has 0 runs. The trigger failed with a 404 on 2026-09-20 and 2026-09-27. The registration stub landed on main in a1f65c6. The first real test is the scheduled run on 2026-10-04 04:41 UTC. On a conflict, async-sync-merge.sh:61-65 exits 2 and async-sync.yml:105-126 opens or comments on an issue. |
gh run list (verified) |
| Push permission risk | async-sync.yml:39-41 grants only contents/issues. Pushes of workflow-file changes with GITHUB_TOKEN (async-sync-merge.sh:85 scratch push, async-sync.yml:283) may be rejected. If the scratch push fails, the job exits 1 (async-sync.yml:102) and no report is made (push-or-report needs success, :271-273). |
Code verified. The rejection itself is unverified: the job has never reached that step. |
| Branch protection | none: rules/branches/async returns []. No push-triggered CI on async (build-matrix.yml:66-77). Async has no AGENTS.md. bin/worktree.sh:37,74-75 always branches from the default branch. |
Verified |
| Dead schedule | async-sync.yml:21-27 cron cannot fire from a non-default branch | Verified |
| Broken unused test | build/test-prepare-patch-stack.sh fails (rc=1): its stubs at :29-40 lack the TLS/ACME files that prepare.sh:111-112 requires. Nothing runs it. |
Run on test box by the auditor |
Bottom line: the Sunday job will at best open a "merge conflict" issue. Do the merge by hand. It is not a text merge: async must stop depending on main's php-src machinery (see Phase 1 in section 10).
3. What to keep (sound parts)
| Part | Why | Evidence |
|---|---|---|
Executor-as-variant plumbing on pool.type=fastcgi |
Data table plus lazy variant install, build_flag error, FPM_TIER_EXPERIMENTAL, requests_per_child as a struct tail field, fiber.* conf fields tail-appended. fpm_tier.c has no diff. Note it is not purely additive: it rewrites some main comments and the body of install_coop_variants. |
fpm_pool_type.c (diff), fpm_pool_type.h:483-490, fpm_pool_type_coop.c:44-69,93-108, fpm_conf.c:256-264 |
| Request state swap (coop) | Swaps SG, OG, symbol table by value (keeps &EG(symbol_table) stable), included_files, http_globals, error/exception handler stacks, session, session_patch, ini, statics |
fpm_pool_coop.c:1-9, 590-666 |
| Fail-closed validation | Refuses ZTS, non-static pm, OPcache, max_execution_time!=0, session.auto_start, negative revalidate_freq, with clear messages. pcntl is disabled at container start. |
fpm_pool_coop.c:234-291, 426-434 |
| Request finalisation ordering | Session RSHUTDOWN before the symbol table is destroyed, upload cleanup, unread POST turns off keep-alive. (It is not a full php_request_shutdown: no shutdown functions, no GC, no other RSHUTDOWN.) |
fpm_pool_coop.c:825-837, 887-890, 919-921 |
can_wait guards, wait_fd cache refresh |
Refuses nested user fibers and zend_fiber_switch_blocked. Calls event_base_update_cache_time. |
fpm_pool_fiber.c:322-339, 342-378 |
Per-request error_reporting |
Comes for free from zend_fiber_vm_state |
php-src Zend/zend_fibers.c:107-121; fpm_pool_coop.c:815-816 |
| I/O seam + interception registry | One io.c owns libevent; table-driven xport/flock/sleep/select; fiber.disable_interceptions; the CI grep enforces layering. Maps 1:1 onto an io_hooks backend. |
fpm_pool_fiber_intercept.c:56-62,134-137,152-186; build/test-fiber-io-seam.sh run at build-matrix.yml:280 |
| evdns-in-connect, select conversion with blocking fallback | Correct conversion. The fallback is safe for efds, zero timeout, and cannot-suspend cases. | fpm_pool_fiber_io.c:257,315-322; fpm_pool_fiber_select.c:60-150 |
| ini value swap, statics resolver, revalidate-as-drain | Cheap paths, no autoload, reference-safe moves, no per-request stat | fpm_pool_coop_ini.c:111-113; fpm_pool_coop_statics.c:426-456,480-518; fpm_pool_coop_reval.c:76-100 |
| ps_globals swap + replayed session RINIT/RSHUTDOWN | Low coupling, no ext/session patch, fixes the measured "recursive save handler" failure | fpm_pool_coop_session.c:158-250; fpm_pool_coop.c:831 |
| #520 merge-deletion guard and its replay test | Catches the #373 class of silent deletion | async-sync-merge.sh:54-85; test-async-sync-guard.sh:69-82 |
| Fiber CI cell's two-layer assertion | A configure-log grep plus a PASS-only whitelist. Ran green on #565 (fiber job 8m47s). Move it, do not drop it. | build-matrix.yml:737-745,791-794; assert-fiber-tests-ran.sh:47-82 |
| Main-side trigger + stub, single-issue failure reporter | Right design; the 404 fix is still unproven | main async-sync-trigger.yml:23-24; ci-report-async-sync-failure.sh:31-48 |
| Best phpts as templates | Worker-clock overlap, bail() + terminate, negative controls |
fpmng-fiber-sleep-concurrency.phpt:149-176 |
| No async code leaks into main from the session/I/O areas | Main has no fpm_pool_fiber_*/coop files and no 0007/0008 | git ls-tree origin/main (verified) |
4. Old, dead or bad
| Item | Evidence | Action |
|---|---|---|
True Async executor fpm_pool_async.c (577 lines) |
validate always returns -1 (fpm_pool_async.c:71-80); it duplicates the coop reject list and hooks (:56-69, :144-183); the real child_main exists only with a forked zend_async_API.h (:42-47). #530 (2026-10-01): "stays open as the record…; if IO Hooks is accepted, the plan is to delete fpm_pool_async.c". |
Delete — Piotr decides (this goes beyond the recorded #530 decision). If deleted, also change config.m4:680-718, build/prepare.sh:94,105,110 (:110 exits when the file is missing), the async executors row in fpm_pool_type.c, fpm_pool_type_coop.c:71-90, tests/fpmng-config-rejected-directives.phpt:18-95,381-389, docs/async_errors.md, README.md:74, and the --enable-fpmng-async flag at async-sync.yml:211. |
fpm_pool_coop_session_lock.c.notbuilt (461) + fpm_pool_coop_session_lock.h (70) |
Not matched by FIBER_PATTERN (prepare.sh:93), excluded from lint (lint-c.sh:38), skipped by the seam check (test-fiber-io-seam.sh:20), and calls seam-forbidden primitives (.notbuilt:159,201,212,214). Nothing includes the .h; the .h at :22 names a non-existent .c. | Delete. Keep docs/session-lock-arbiter-report.md as the record. Fix references at fpm_pool_coop_session_patch.c:4,60 and .h:3,61. |
fpm_pool_fiber_validate() |
Defined fpm_pool_fiber.c:35-39, declared .h:24, never called; the type uses fpm_pool_type_coop_fiber_validate (fpm_pool_type_coop.c:20-35) |
Delete |
fpm_coop_reval_enabled() |
fpm_pool_coop_reval.h:33, no callers | Delete |
FPMNG_SHARED_INCLUDES env var |
getenv at fpm_pool_coop.c:413-423. It is documented (README.md:224,237,253; docs/frameworks.md:8,19), but it is the actual operating mode for Composer apps, not a validated directive (#78). It also leaks into the app's environment. | Rewrite as a validated fiber.* pool directive after #78 |
| flock "SPIKE" leftovers | FPMNG_FLOCK_POLL_ATTEMPTS env knob (fpm_pool_fiber_flock.c:61-72); "SPIKE" label (flock.h:1); a false "bounded"/"correctness is unaffected" comment and warning (flock.c:94-96, 466-476) |
Rewrite (see io-3/4/7 in section 5) |
| Stale header/comments | fpm_pool_fiber.h:4-5 ("no fork and no patches"; 0007/0008 are required); fpm_pool_coop.h:27-29 ("two types"), ~113-115 (accept "blocking"), ~135 (in_flight a "log statistic"), ~139; fpm_pool_fiber_xport.c:40-41 lists sleep() as not caught; core_globals.precision at fpm_pool_coop_ini.c:85 and coop.h:20 (it is EG(precision), php-src main.c:305-309); fpm_pool_type_coop.c:74-77 "no CI builds async" (async-sync.yml:211 does); build-matrix.yml:671-676 #87 comment (#87 is closed) |
Rewrite |
fpm_pool_type.c comment naming fpm_pool_http_fiber |
Also on main (origin/main fpm_pool_type.c:550) | Fix on main |
| Polish strings | fpm_pool_coop_session.c:174-176, 195-197, 235-236 (zlog); fpm_pool_fiber.c:399 (comment). Missed by build/check-english.py. | Rewrite in English; teach the checker ASCII-folded Polish |
| Patch 0007 header and patches/README "Why 0007" over-claims | Claims the TCP connect and DNS suspend (0007:7-9; xport.c:32; docs/fiber_async_io.md:14-37), which is false for ssl:// (io-1). The "byte-for-byte upstream" claim (0007:5) is false because of an ungated whitespace hunk (0007:310-312). | Rewrite |
Patch 0007 dead SSL_MODE_AUTO_RETRY hunk |
OpenSSL ≥1.1.1 is required (ext/openssl/config0.m4:4) and upstream never clears the mode | Delete hunk |
| Patch 0008 stale reference | Cites tasks/nice-to-have/006-fiber-stream-select.md; tasks/ is gone. No "Why 0008" section in patches/README.md; checked only on 8.5.11-dev (README:36). |
Rewrite |
| Patches 0001-0005 still on async | Main removes them (#589-#592). #589: 0004's fcgi_set_optimized_transport is called on neither branch. 0007/0008 do not depend on them. |
Delete on async with the merge |
docs/fiber-risky-builtins.csv (367 rows) |
Not referenced anywhere; single real commit ca1be85. Stale rows: row 30 files_arb, row 121 cites removed patch 0006, rows 24/227 cite xport.c:673; :42 wrongly says shutdown functions "run at process exit". |
Rewrite: fix the rows, link from fiber_errors.md, turn the high rows into issues/tests |
| docs: fiber_async_io.md "Overriding note" | :191-198 says Composer apps "still fail", contradicting README.md:225-252; :152 calls the seam "syscall-first" (xport is poll-first, xport.c:179-183) | Rewrite |
| docs: shutdown-timeouts.md:26 | Claims fiber/async enforce request_terminate_timeout; coop rejects it (fpm_pool_coop.c:43-46). Also names the retired http type. |
Rewrite |
| docs: fiber_errors.md | Retired names http/fiber, http/classic (:117,128,167,180,181,380); wrong path docs/sleep-yield-report.md (:167); says fpm_conf.c untouched (:15); names zend_restore_ini_entry (:31,:59) where the code uses zend_ini_deactivate |
Rewrite |
| docs: frameworks.md:358-364 | Says sleep/usleep do not suspend; fpm_pool_fiber_sleep.c:241-244 swaps them | Rewrite after the framework re-run |
| docs: flock-streams-spike-report.md:300-330 | Nested-Fiber fallback is stale (code returns EWOULDBLOCK, flock.c:373-408); overflow called harmless | Mark historical |
| sapi/fpmng/README.md:12,16; README.md:74 | Executors "for http" (retired #388); async tier "experimental" though always refused |
Rewrite |
| tests/statics_reference.php | Manual, wired nowhere; header :9-13 says sleep does not suspend (stale) | Delete or convert to phpt |
| Shared-file edits on async with no runtime value | ci-package-gate.sh EXPECT_TOTAL 177 (already wrong: async has 178 fpmng-.phpt, merged tree 191; main is at 179, :508); comment-only hunks in fpmng-http-direct-worker.phpt and fpmng-http-gateway-log-reopen.phpt; docs/spike-.md, shutdown-timeouts.md, http-direct.md, fpmng-phpt.md | Revert to main's version on async |
| Fiber cell inside shared build-matrix.yml; async edits to prepare.sh (+36/-15) | Guaranteed conflicts with every main CI/prepare change (#592 will rewrite prepare.sh) | Move into async-only files |
Symfony harness writes pool.type = fastcgi-ng |
tests/frameworks/symfony/run.sh:267,289 (retired in #376/#379); only the unsupported-config and async scenarios break | Rewrite (#449) |
5. Bugs and risks
Severity follows the verified impact. "Inf." means the effect is inferred from code and not reproduced.
| # | Severity | Bug / risk | Evidence | Status |
|---|---|---|---|---|
| B1 | high | A fatal (E_ERROR class, including Allowed memory size, Cannot redeclare) in one request poisons the child: gc_protect(1) stops cycle GC, CG(unclean_shutdown)=1 (also changes generator destruction, Zend/zend_generators.c:157,283), zend_objects_store_mark_destructed on the shared store means no __destruct for any in-flight request (no rollback, unlock, log flush), and zend_set_memory_limit resets the process limit. A fatal can be caused by another request's memory use. |
php-src main/main.c:1528-1540; Zend/zend.c:1263-1264; Zend/zend_gc.c:570; fpm_pool_coop.c:692-735 (zend_try only sets exit_status); fpm_pool_fiber.c ~272. No reset anywhere in sapi/fpmng (grep). Uncaught exceptions are not affected (E_DONT_BAIL). | Code verified; runtime not reproduced |
| B2 | high | SIGQUIT (reload/stop) abandons all in-flight requests; a classic child finishes its request. The drain exists but is used only for revalidate. | fpm_pool_fiber.c:447-450, 469-471, 590-596; drain :480-503; upstream fpm_signals.c:142-153 | Verified |
| B3 | high | A dropped request (user Fiber::suspend() in the request fiber) never decrements in_flight and is never freed: fiber, ctx, symbol table, SG copy, ps_globals with an open ps_files fd + kernel flock, statics stash, and the in-process session lock. A later revalidate drain removes accept and never finishes; the child serves nothing until reload/stop. |
fpm_pool_fiber.c:150-171 (no req_free / in_flight--); fpm_pool_coop.c:585, :960-962; drain :191, :483, :499; session arbiter not in the registry (fpm_pool_coop_session_patch.c:116-124). Documented in docs/fiber_errors.md:337-340, no issue. | Code verified; not reproduced |
| B4 | high | register_shutdown_function callbacks never run, not even at exit (child ends with C exit(), fpm_pool_fiber.c:596). The process-wide list and everything its closures capture grow per request (Symfony registers session_register_shutdown() per request, NativeSessionStorage.php:90). |
fpm_pool_coop.c:736-963 (no php_call_shutdown_functions); fpm_pool_coop.h:15 | Verified; growth not measured |
| B5 | high | ssl://, tls://, https:// connects do blocking DNS + TCP connect (whole worker). The current TLS phpt cannot detect it (IP literal, instant accept). A fix must connect first, then enable crypto (xp_ssl.c:2536-2546 starts the handshake on EINPROGRESS). | patch 0007:299-301,348-434; php-src xp_ssl.c:2530-2547; xp_socket.c:543-550; http_fopen_wrapper.c:449 | Code verified |
| B6 | high | The xport read pre-waits on the raw fd and ignores SSL_pending: a partly consumed TLS record can make the next read wait for the full stream timeout. mysqlnd mostly safe (32 KB chunks); phpredis-over-TLS at risk. |
fpm_pool_fiber_xport.c:195-213; has_buffered_data is a streams flag, not an OpenSSL check; upstream xp_ssl.c:1947-2060 |
Code verified |
| B7 | high | flock registry keeps a holder after fclose/file_put_contents(LOCK_EX) without LOCK_UN; waiters WAKE with no deadline. Same-process waiters stall until the holder's request ends; AB/BA deadlocks forever. |
fpm_pool_fiber_flock.c:205-254, 311-326, 411-415; php-src plain_wrapper.c:508; file.c:485,568 | Code verified; deadlock not reproduced |
| B8 | high | Cross-process flock (5×20 ms poll, then blocking flock(2)) and session files (mod_files raw flock(2)) freeze the whole worker. Two workers, each with a suspended holder, can freeze each other permanently. Nothing kills them (no timeouts). |
fpm_pool_fiber_flock.c:437-481; php-src mod_files.c:209-211; docs/session-lock-arbiter-report.md:744-761 (4 s wait reported as PASS) | Blocking verified; deadlock is inference |
| B9 | high | Session arbiter use-after-free: wakes are deferred (fpm_pool_fiber.c:419-429) and acquire allows barging, so an entry can be freed while a woken waiter still holds a pointer to it. The comment at :181-185 claiming the fix is wrong. | fpm_pool_coop_session_patch.c:221-271, dtor :152-163 | Code verified; not reproduced |
| B10 | high | session_reset() self-deadlocks: psw_open overwrites *mod_data without closing (mod_files closes it, mod_files.c:418-420); the request then waits forever on its own lock, or (if it cannot suspend) blocks in kernel flock. |
fpm_pool_coop_session_patch.c:274-281, :247; session.c:1777-1783, :444, :492 | Code verified |
| B11 | medium | Session lock owner can be wrong or stale: psw_current_ctx is set only for live requests (first entry is not live) and never cleared. A force-release can hit a lock that another request holds; double release then breaks mutual exclusion; psw_owners keeps one key per ctx. |
fpm_pool_coop_session_patch.c:148,520,203-218,526-546; fpm_pool_coop.c:596,611,772 | Code verified |
| B12 | medium | Arbiter is permanently inert when the pool's baseline session.save_handler is not files; a runtime ini_set(...,'files') (e.g. Symfony native_file in that setup) gets no protection. (When the baseline is files, native_file is protected.) |
fpm_pool_coop_session_patch.c:414-420; Symfony NativeFileSessionHandler.php:57-59 | Code verified |
| B13 | medium | No per-request time limit and no kill path: max_execution_time must be 0; request_terminate_timeout, slowlog and pm.max_requests are rejected. A CPU-bound fiber also starves the libevent tick, so an in-tick watchdog cannot work. |
fpm_pool_coop.c:43-55, 256-272, 466; fpm_pool_fiber.c:463-477 | Verified |
| B14 | medium | No in-flight cap; requests_per_child=128 is unmeasured and not enforced, yet the gateway multiplies capacity by it (#529). |
fpm_pool_fiber.c:441-460; fpm_pool_type_coop.c:63-68; fpm_http.c:4113-4114 | Verified |
| B15 | medium | FastCGI accept + header read run synchronously in the event loop (poll(fd,5000) + blocking read), the POST body read is blocking, and writes go to a blocking fd. A slow client stalls every fiber. This is the concrete candidate root cause for #549. On BSD, O_NONBLOCK is cleared only after the header read (the "harmless" comment at coop.c:507-511 is questionable). |
fpm_pool_coop.c:343-368, 491-519, 553-568; php-src main/fastcgi.c:1351,1440 | Read path verified; write blocking and BSD effect are inference |
| B16 | medium | EG(error_handling)/EG(exception_class) not swapped. While one request is suspended inside PDO::__construct (EH_THROW), warnings from other requests become PDOException, even with a user error handler (zend.c:1531-1534). Also unswapped: PG(last_error_*) (error_get_last()), EG(in_autoload), EG(filename_override). |
fpm_pool_coop.c:590-666 (absent, grep); php-src pdo_dbh.c:482-530; main.c:1368-1383 | Code verified; cross-request effect not reproduced |
| B17 | medium | ini isolation without on_modify (#79): ini_get() is per request, behaviour is process-wide; request end runs on_modify(baseline) and resets globals of live requests. The phpt checks only ini_get. |
fpm_pool_coop_ini.c:58-99,118-133; fpm_pool_coop.c:880; tests/fpmng-fiber-request-isolation.phpt:49,59 | Verified |
| B18 | medium | isolate_statics leaves a finished request's values (e.g. Laravel's whole Application via Container::$instance) in the live slots; the next request that enters dtor's them in its own SG/OG context, before zend_try. |
fpm_pool_coop.c:645-647, 960; fpm_pool_coop_statics.c:551-575; fpm_pool_fiber.c:266-269 | Order verified; destructor-context effect inferred |
| B19 | medium | Function/method static $x variables are not covered by isolate_statics; docs/frameworks.md:673-677 wrongly dismisses them as "local state". |
fpm_pool_coop_statics.c:431-455; php-src zend_vm_def.h:9133-9147 | Verified; whether frameworks keep request state there is unverified |
| B20 | medium | Persistent-socket refusal can be bypassed: it applies only when can_suspend (xport.c:644; same gate in 0007 at patch line 362), and EG(persistent_list) hits return before the factory (transports.c:75-93). A persistent socket created in a nested Fiber or under switch-block is then shared. |
as cited | Code verified |
| B21 | medium | DNS-in-connect for tcp:// depends on which stream type is wrapped first: tcp and ssl share one ops-map entry keyed only by ops; the TLS path passes dns=false. |
fpm_pool_fiber_xport.c:582-610, 710-714; php-src openssl.c:438 | Code verified |
| B22 | medium | flock registry never frees entries; after 4096 distinct inodes it silently falls back to blocking flock (in-process deadlock returns). | fpm_pool_fiber_flock.c:27-29, 77-106, 336-338 | Verified |
| B23 | medium | revalidate keys on realpath, so symlink deploys are not detected (documented: fiber_errors.md:326-329, #80); the stat sweep runs synchronously in the event loop. | fpm_pool_coop_reval.c:108-119, 140-176; fpm_pool_fiber.c:509-530 | Verified; "mixed release" consequence is inference |
| B24 | low | set_time_limit(N) works when max_execution_time=0 comes from php.ini, and arms the single process timer, so SIGPROF can hit another request (fiber_errors.md:61-66 admits this). |
fpm_pool_coop.c:262-273, 880-885 | Verified |
| B25 | low | Large writes block when the socket timeout is −1 (not the default of 60), and upstream php_sockop_write polls with blocking after EAGAIN (xp_socket.c:73,86). |
fpm_pool_fiber_xport.c:216-230 | Verified |
| B26 | low | time_nanosleep truncates ns→µs (#84 gap 1); time_sleep_until, pcntl_sleep not intercepted. |
fpm_pool_fiber_sleep.c:197, 239-252 | Verified |
| B27 | low | Engine-internals hacks with no per-version check: EG(current_execute_data)=NULL (coop.c:706-729), byte memcpy of SG/OG/HashTables, ps_globals via mh_arg2 + sizeof(zend_ps_globals), and a ce_flags CONSTANTS_UPDATED check that fails open for immutable (opcache) classes (statics.c:446; php-src zend_API.c:1546-1557). |
as cited | Verified (c dormant while OPcache is refused) |
| B28 | low | A wake is not tied to a specific wait (one shared fr->ev). All current wakers were checked and are safe; this is latent fragility, not a present bug. |
fpm_pool_fiber.c:313,356,402,427-428; fpm_pool_fiber_io.c:97-105 | Verified as latent |
| B29 | low | Session address self-check compares cookie_lifetime (default 0); use pointer identity on save_path instead. Resolver duplicated for no reason. |
fpm_pool_coop_session.c:137-150; patch.c:376-391 | Verified |
| U1 | unverified | GC-triggered destructors run in whichever request is swapped in (output to the wrong client). php-src 8.5 runs GC destructors in a separate fiber, where can_wait returns 0. |
fpm_pool_coop.c:587-650; Zend/zend_gc.c ~2095 | Unverifiable without a runtime test |
| U2 | unverified | memcached / phpredis native session locking spin with C usleep, which is not intercepted |
fpm_pool_fiber_sleep.c:60-70 swaps zif handlers only | Extension sources not read |
| U3 | unverified | GITHUB_TOKEN push of workflow changes is rejected (infra B, section 2) |
— | Not observed |
6. php-src patch inventory (async tree)
| Patch | Purpose | Size (lines) | Upstreamable | io_hooks replaces? | Action |
|---|---|---|---|---|---|
| 0001 gh18956 fastcgi keepalive counting | fastcgi perf/fix | 167 | upstream bug track (gh18956) | n/a | Drop on async with the main merge (#591) |
| 0002 fastcgi TCP_NODELAY | fastcgi perf | 35 | n/a | n/a | Drop (#590) |
| 0003 fastcgi buffered read / accept4 | fastcgi perf | 170 | n/a | n/a | Drop (#589). Note: does not change the blocking header read (B15). |
| 0004 fastcgi-ng transport switch | fcgi_set_optimized_transport |
96 | n/a | n/a | Drop (#589: called on neither branch) |
| 0005 fastcgi writev large response | fastcgi perf | 132 | n/a | n/a | Drop (#589) |
| 0006 | removed earlier (#420) | — | — | — | Fix the CSV row 121 reference |
| 0007 fiber TLS nonblocking transports | Weak-symbol hooks in ext/openssl xp_ssl.c: 5 php_pollfd_for sites (1 handshake + 4 sockop_io) → fpm_fiber_tls_wait; TLS factory with persistent/blocking refusal |
466 | Not as is (fpmng-specific weak hooks) | Yes (wait sites); the persistent-refusal policy stays SAPI-side | Keep until io_hooks. Fix: drop the SSL_MODE_AUTO_RETRY hunk (no-op) and the ungated whitespace hunk (0007:310-312); fix ssl:// connect/DNS (B5); fix the header over-claims; the liveness poll at xp_ssl.c:2469 stays blocking. Move to an async-only patch dir (Phase 1). |
| 0008 fiber stream_select | One weak-symbol call site in stream_select |
74 | Small, but superseded by io_hooks | Yes | Keep until io_hooks. Add a "Why 0008" section and the tested versions (only 8.5.11-dev is recorded; CI uses 8.5.9 and the fiber cell is green); remove the tasks/ reference. Move to an async-only patch dir. |
Not covered by io_hooks, per #600 and the audits. These need their own answer:
- in-process flock registry semantics;
mod_filesrawflock(2)(B8): options are a php-src patch, link-time--wrap=flock(static ext/session only), or refusingsave_handler=fileswith pm > 1;- the session-lock arbiter;
- the persistent-connection policy (#550);
- OPcache, the per-request heap and
memory_limit, per-requestmax_execution_time, fatal-error object-store marking (B1).
The I/O seam already resembles the io_hooks shapes, with these gaps: struct timeval* instead of an ns deadline (fpm_pool_fiber_io.h:82), only READ/WRITE bits (:41-42), no result struct, no Done-vs-Ready distinction, and no per-waker cancel. The comparison with the RFC text relies on the #599 summary; it was not checked against the RFC itself.
7. Leaks into main and drift
On main, async-only (remove or move to async)
| Item | Evidence | Tracking |
|---|---|---|
tests/frameworks/ (100 files, all fiber-only) |
main tests/frameworks; tests/frameworks/README.md:35-36 "Executor matrix: fiber-only"; slim4/bin/run.sh:192, symfony/run.sh:34,241 | #602 (opened 2026-10-03, v0.14.0) |
README.md:212-251 "Framework support on pool.executor = fiber … supported" vs README.md:71 "moved to branch async"; README.md:295-297 non-existent label track:nice-to-have |
main README.md | #601 |
| docs/frameworks.md (722 lines, fiber-only) | main docs | #601 (some spike/history docs stay on main on purpose per #373) |
Dead seams in C: empty fpm_pool_type_install_coop_variants called on every type lookup; build_flag never assigned; unreachable "lives on branch async" message; fiber. prefixes in reject lists |
main fpm_pool_type.c:267-276, :647, :818-822, :90; fpm_pool_type.h:111-113; fpm_pool_supervisor.c:100; fpm_pool_cron.c:147; fpm_operator_endpoint.c:51; fpm_http_direct_request.h:82; contradicted by fpm_pool_type.c:34-39 | Decision: these keep async's diff additive (#371). Either remove the empty hook and the unreachable message, or tag them "async seam, see #371". Keep the reserved configure flags (config.m4:680-702). Put this in #601's cleanup. |
Stale fpm_pool_http_fiber comment |
origin/main fpm_pool_type.c:550 | Fix on main |
| No test for listening-socket O_NONBLOCK normalisation on main | main fpm_pool_type.c:899 (used by http-direct, :216,:389); the tests moved to async in 65619cd and use fixed ports 26037/26039 | Port the tests to main with an http-direct↔classic reload and tester-allocated ports |
On async, should come from main or be reverted
- The 27 main commits: the no-php-src contract (#424/#422/#421), the standard files (AGENTS.md, docs/workflow.md, bin/), and the patch removals (#589-#592).
- Shared files async edits for no benefit (section 4, "Revert"): ci-package-gate.sh, libphp-build.sh comments and off_reason, docs/spike-*.md, docs/fpmng-phpt.md, comment-only phpt hunks.
build/prepare.shFIBER/ASYNC split (+36/-15). This is the only place fiber sources are kept out of the default build, and #592 will rewrite prepare.sh. Move this logic into an async-only script.
8. What blocks Symfony / Slim / Laravel
| Blocker | Fix class | Evidence |
|---|---|---|
| OPcache refused | Open: php-src may not be needed. The refusal gives two reasons. (1) The auto-globals mask is reset once per container (ZendAccelerator.c:2660), but req_run already forces $_SERVER/$_ENV/$_REQUEST (fpm_pool_coop.c:810-812). (2) ZCG(request_time) is frozen (:2661), which fiber.revalidate_freq plus validate_timestamps=0 might cover. Also, the statics ce_flags check fails open for immutable classes (B27). Needs an investigation, not an assumption. |
fpm_pool_coop.c:225-256, 404-407 |
| Fatal error poisons all in-flight requests (B1) | SAPI can contain it (drain and exit the child after any bailout). True isolation needs php-src (per-request object store/heap). | section 5 B1 |
Shared memory_limit / heap |
php-src for per-request limits; SAPI: in-flight cap (#529) as a bound | fpm_pool_coop.h:12-25 |
| No per-request time limit, no kill path | SAPI: an external timer/signal or a master-side watchdog, plus fiber-level recycling (max requests → drain). A real per-request max_execution_time needs php-src. |
B13 |
| Shutdown functions never run (B4); Monolog flush, Symfony/Laravel fatal reporting depend on them (framework impact is inference) | SAPI (swap BG(user_shutdown_function_names), run at end of req_run) |
B4; #82 lists it |
Composer apps need shared includes; require_once returns true; hand-written index.php |
Needs app (entry-script contract) + SAPI directive (#78) | fpm_pool_coop.c:85-92,413-423; docs/frameworks.md:40-60 |
| Class statics isolation is a hand-curated deny-list; a missing entry gives silent wrong data | Needs app (per-framework, per-version list) or SAPI "isolate all user statics" spike (cost unmeasured; may reset boot-once caches) | docs/frameworks.md:604-641; fpm_pool_coop_statics.c:459-561 |
Function static $x not isolated (B19) |
SAPI (extend) or accepted limitation | B19 |
on_modify ini globals (#79): precision, display_errors, include_path, date.timezone, bcmath.scale |
SAPI | B17 |
| Unswapped EG/PG fields, PDO EH_THROW leak (B16) | SAPI | B16 |
Process-wide builtins: putenv, chdir, umask, setlocale, date_default_timezone_set, spl_autoload_register, define; posix_kill reachable |
SAPI (swap or refuse per row) + needs app for the rest (#82) | fpm_pool_coop.c:73-76; docs/fiber-risky-builtins.csv:5,33,34,41,42,46,49 |
| curl (Guzzle/Symfony HttpClient defaults), pdo_pgsql, memcached, DNS functions, proc_open/exec pipes, udp, plain file I/O block the process | php-src (io_hooks) for curl (#81 deferred to #600); needs app meanwhile (NativeHttpClient / Guzzle StreamHandler, MySQL rather than pgsql) | fpm_pool_fiber_xport.c:40-45, 740-761; docs/fiber_async_io.md:62-83 |
| ssl:// / https:// connect + DNS block (B5), SSL_pending stall (B6) | SAPI (0007/xport) now; io_hooks later | B5, B6 |
Session files handler: cross-process blocking, arbiter bugs (B8-B12) |
SAPI for the arbiter; for raw flock(2): php-src patch, link-time wrap, or needs app (Redis/PDO sessions). Laravel does not use ext/session (docs/frameworks.md:5-6,145-151). |
B8-B12 |
| Persistent connections refused (pconnect, PDO persistent); bypassable (B20) | Needs app (no persistent connections) + SAPI fix for the bypass | #550 |
Nested user fibers (Revolt/AMPHP) block instead of yielding; Fiber::suspend() in the request fiber drops the request |
Needs app; SAPI should fail loudly (500 + log) and clean up (B3) | fpm_pool_fiber.c:150-171, 331-336 |
| Symlink deploys not detected (#80) | SAPI (re-resolve the deploy root per sweep); SIGUSR2 meanwhile | B23 |
| #50: Redis reply misattributed under the configured Laravel list, root cause unknown | Unknown. This is the most framework-relevant open correctness bug; Slim uses predis and does not exercise it (docs/frameworks.md:504). | #50 |
| Framework evidence is stale, not in CI | Process | docs/frameworks.md:3,488-492,502,653; no workflow references tests/frameworks |
9. Async milestone triage
| Issue | Verdict | Reason | Action |
|---|---|---|---|
| #600 io_hooks tracking | valid | io_hooks would replace 0007, 0008, xport, sleep, select and curl, but not flock, the session lock or persistent refusal | Record the replace/keep split from section 6 |
| #599 seam alignment with io_hooks | valid, low | Shape gaps listed in section 6 | Close the shape gaps (ns deadline, ERR/HUP, result struct, per-waker cancel); link #84 |
| #551 flock test flake | valid, rescope | The same precondition flaw affects the EX (:184,194) and SH (:216-228) batches, not only NB (:239) | Fix all three batches (assert waiter.start > holder.acquired; bounded retry) |
| #550 persistent refusal | valid, widen | Bypass via EG(persistent_list) and the can_suspend gate, in xport (xport.c:644) and 0007 (line 362) |
Refuse for the whole fiber executor; check persistent_list hits; add a phpt |
| #549 select overshoot ~500 ms | valid, root cause open | The select conversion is correct. Prime suspect: synchronous fcgi_accept_request in the loop (coop.c:491-499, fastcgi.c:1351). The gateway http.idle_timeout 500 ms default (fpm_http.c:174) is a suspicious, unverified match. |
Reproduce with timestamps on the test box; add a timing assertion |
| #530 async executor | decision record | Code is dead (validate −1); the 2026-10-01 comment keeps it as a record until io_hooks | Piotr: delete now (section 4 list) or keep. Recommendation: delete; git history plus #530 keep the record. Fix the stale "no CI builds async" sentence either way. |
| #529 in-flight cap | valid, raise priority | No cap; 128 unmeasured; the gateway relies on it | Implement as proposed; measure memory per fiber |
| #528 async-sync never ran | valid until proven | Stub landed (a1f65c6); next run will hit 7 conflicts; push may fail (U3); a failed scratch push is silent | Keep open until a run after the manual merge is green; report the conflicted files in the issue body |
#449 Symfony harness fastcgi-ng |
valid on async; main copy goes with #602 | run.sh:267,289; only two scenarios broken | Fix on async; delete on main (#602) |
| #85 session RINIT order | valid, narrow | Only ext/session RINIT is re-run per request; the "other extensions" survey is moot (fpm_pool_coop.c:779-785 vs php-src main.c:1942-1943) | Split startup into copy-globals → auto-globals → RINIT; then reconsider the auto_start refusal |
| #84 sleep gaps | valid, half done | Gap 2 fixed (sleep.c:107-119); gap 1 (ns truncation, :197) remains; time_sleep_until not swapped |
Update the issue; round up; add time_sleep_until |
| #83 Slim | rescope or close | Slim measured (11 PASS); only the baseline, pm > 1 and revalidate rows remain | Fold into the framework re-run issue |
| #82 process-wide builtins | valid, rescope | The CSV exists (criterion 1 done); per-row decisions are open; register_shutdown_function is explicitly listed here |
Comment: add B4 (callbacks never run, list grows); posix_kill; the high CSV rows |
| #81 curl / pgsql | valid | Owner deferred curl to io_hooks (#600); the workaround (stream-based clients) is documented in the issue | Keep the decision; document the client requirement in docs/frameworks.md |
| #80 symlink deploys | valid | B23; already documented (fiber_errors.md:326-329) | Implement the deploy-root re-resolve; consider capping the sweep |
| #79 on_modify ini | valid, priority medium (labelled) | B17; most important isolation bug together with B16 | Replay on_modify for "pure" directives in the request's own mod list; fix the EG(precision) comment |
| #78 include model | valid decision, overdue | Shared includes is the de-facto mode | Decide option A: a validated fiber.shared_includes directive (default on) |
| #52 statics cost | valid, low | Code bound: ~2 hash lookups per item per switch (statics.c:431-435). Laravel defaults not checked. | Merge into the "isolate all statics" spike, or close with the bound |
| #50 Redis reply misattribution | valid, raise | Silent wrong data, root cause unknown. Candidates to rule out: B16, B18, B20, shared persistent sockets. | Reproduce on the test box with phpredis |
Proposed new issues
Search existing issues first. Create them without a milestone (lead's step 7). Items already tracked are excluded (#82 for shutdown functions, #529 for the cap, #601/#602 for main docs/tests).
- fiber: a fatal error (bailout) poisons the whole child. After any bailout in
fpm_coop_execute/switch_in, the GC stays protected,unclean_shutdownstays set and every in-flight object is marked destructed (main.c:1528-1540, zend.c:1263-1264). Drain and exit the child after a bailout; add a fatal-error phpt. - fiber: SIGQUIT abandons in-flight requests. Use
fpm_fiber_drain_begin()onfcgi_in_shutdowninstead of breaking the loop (fpm_pool_fiber.c:447-450,469-471,590-596). The master'sprocess_control_timeoutstays the hard stop. - fiber: a dropped request leaks in_flight, its ctx and its session lock; the drain hangs. fpm_pool_fiber.c:150-171 never reaches coop.c:962 or
req_free. Addfpm_coop_req_abandon(), a sessionrequest_endhook, a drain deadline and a drain-after-drop phpt. - fiber: request time limit and recycling. No
max_execution_time,request_terminate_timeoutorpm.max_requests(coop.c:43-55). Add a fiber-level max-requests (start drain at N), and a watchdog outside the event loop (timer signal or master side), because a CPU-bound fiber starves the tick. - fiber: non-blocking FastCGI header and body read. The accept/header read (fastcgi.c:1351,1440),
fpm_coop_read_post(coop.c:343-368) and writes block the process. Read throughwait_fdbefore dispatch. Meanwhile document the nginx buffering requirement. - fiber: ssl:// / tls:// / https:// connect and DNS block the worker. Upstream CONNECT bypasses the wrapped ops (xp_ssl.c:2530-2547). Connect through the xport async path first, then enable crypto. Fix the 0007 and doc over-claims.
- fiber: xport TLS read ignores SSL_pending; DNS flag is order-dependent. xport.c:195-213 pre-wait; xport.c:582-610 map keyed only by ops. Use a syscall-first read for TLS; key the map by (ops, dns).
- fiber: flock registry correctness. Release on close without
LOCK_UN, free unused entries, put deadlines onWAKE, replace the blocking cross-process fallback with TIMER backoff (flock.c:205-254,311-326,437-481,77-106). Remove the SPIKE label and the env knob. - fiber: session lock arbiter bugs. UAF under barging (patch.c:221-271),
session_reset()self-deadlock (:274-281), stale owner (:148,520), double release after force-release (:542-544). Hand ownership to the waiter directly; close the oldmod_dataon reopen. - fiber: session
fileshandler and cross-process flock(2).mod_files.c:209-211freezes the worker; ABBA across workers (inference). Decide: php-src patch, link-time wrap, or refusefileswith pm > 1. - fiber: swap EG(error_handling), EG(exception_class), PG(last_error_*). The PDO EH_THROW leak hits other requests even with user handlers (pdo_dbh.c:482-530, zend.c:1531-1534). Also keep a checked-in table of every
init_executor/php_request_startupfield and whether it is swapped. - fiber: isolate_statics keeps a finished request's values; function statics are not covered. Reset the slots at the end of req_run (coop.c:960; statics.c:551-575); decide on function statics; correct docs/frameworks.md:673-677.
- fiber: OPcache investigation. Test whether the forced auto-globals plus
validate_timestamps=0and revalidate drain are enough (ZendAccelerator.c:2660-2661). Fix thece_flagscheck for immutable classes first. - async: re-base the branch on main's no-php-src contract. Async-only patch dir and apply script, async-only fiber workflow, exclude fiber sources from the SDK glob (libphp-build.sh:345-353), revert the shared-file edits, then the manual merge with
allow_deletions. Also: the push token, a ruleset,bin/worktree.sh --base async. - fiber: re-run the framework harness on the current head and add it to CI. The last runs predate #379/#531; Symfony ran on 8.6.0-dev on macOS. Run on Linux, pm > 1,
APP_ENV=prod, then weekly. Add phpts for shared includes,isolate_staticsand revalidate. - fiber: phpt suite gaps and vacuous tests.
- request-isolation has no overlap check, its sessions never contend, and it uses bare
exit(1). - The TLS refusal probes target port 1 (tls-concurrency.phpt:232,235).
- TLS and select use a 1.6 s wall-clock bound.
- The disable-interceptions negative control can pass vacuously.
- assert-fiber-tests-ran.sh misses fpmng-tier-experimental.phpt.
- Add the missing tests listed in Phase 4.
10. Recommended plan
The ordering rule: first make the branch maintainable, then make failures safe, then fix correctness, and only after that measure the frameworks again. Add no new fiber features before Phase 3 is done.
Phase 0: decisions for Piotr (no code)
- #530: delete the async executor now, or keep it as a record. Recommended: delete.
- #78: the include model. Recommended: option A, a validated
fiber.shared_includesdirective, default on. - Persistent-connection policy (#550/#50): refuse all persistent connections under fiber, regardless of
can_suspend. - The session
fileshandler under fiber with pm > 1: php-src patch, link-time wrap, or refuse it (new issue 10). - Whether main keeps the empty async seams (section 7) or removes them. Moving tests/frameworks and docs is already in #601/#602.
- Policy after a fatal error: recycle the child (recommended), or accept the risk.
Phase 1: branch infrastructure (new issue 14, #528, #601, #602, #589-#592)
- Move 0007/0008 into an async-only patch directory with its own apply script, so main's fingerprint (vendor-php-src.sh:82-104) and #592 never touch them. Drop 0001-0005 on async.
- Move the fiber CI cell, and the php-src build path it needs, out of build-matrix.yml into an async-only workflow. Decide whether to keep the frozen ghcr image or install dependencies in the job. Keep the two-layer assertion, and add fpmng-tier-experimental.phpt to the list (or derive the list from SKIPIFs).
- In libphp-build.sh, exclude
fpm_pool_fiber*,fpm_pool_coop*andfpm_pool_async.c, or guard those files with#ifdef HAVE_FPMNG_FIBER. Add arefute_symbolcheck. - Revert async's edits to shared files (ci-package-gate.sh, docs, comment-only phpt hunks). Move the prepare.sh split into async-only code.
- Do the manual main→async merge as a PR into async, with
allow_deletionsfor the 8 paths. Resolve async-sync.yml with async's version. - Fix async-sync: remove the dead schedule, use a GitHub App token or PAT with workflows:write (or open a PR instead of pushing), report a failed scratch push, and list the conflicted files in the issue.
- Add a light ruleset on async (no deletion, no force-push) and
bin/worktree.sh --base async. Close #528 after a green run following the merge. - Fix or delete build/test-prepare-patch-stack.sh.
Phase 2: delete dead code and stale text (one PR on async; #530 if approved)
- Delete the async executor and its dependencies (section 4 list), if Phase 0.1 says so.
- Delete
.notbuilt+.h,fpm_pool_fiber_validate,fpm_coop_reval_enabled, tests/statics_reference.php. - Translate the Polish strings. Fix the stale header comments (section 4) and the "SPIKE" leftovers in comments.
- Patch 0007: drop the AUTO_RETRY and whitespace hunks, fix the header. Patch 0008: remove the
tasks/reference, add "Why 0008" to patches/README.md.
Phase 3: lifecycle safety (new issues 1-4, #82, #529)
- Bailout → drain and exit the child (B1), with a fatal-error phpt that includes a concurrent request with
__destructand agc_status()check. - SIGQUIT → graceful drain (B2), with a reload-with-request-in-flight phpt.
- Dropped request: decrement
in_flight, release the session lock and closemod_data, free what is safe, log, and answer 500. Add a drain deadline (B3). Extend the dropped-request phpt with a following drain. - Shutdown functions per request: swap
BG(user_shutdown_function_names); run and free them at the end of req_run (B4). Comment on #82. - In-flight cap (#529), measured; fiber-level max requests → drain; a request watchdog outside the event loop (B13).
- Add the first drain/revalidate phpt (none exists today).
Phase 4: isolation and I/O correctness (#79, #84, #85, #549, #550, #551, #80; new issues 5-12)
- Swap the EG/PG error fields (B16). Implement the
on_modifyreplay (#79/B17) and assert real behaviour (precision) in a phpt. - Reset the
isolate_staticsslots at request end (B18). Decide on function statics (B19). Forcemax_execution_timeto SYSTEM 0 if Piotr agrees (B24). - TLS: the ssl:// connect path (B5), the SSL_pending read (B6), the DNS map key (B21). Add phpts with a slow-accept listener and a large-record read.
- flock: close-without-unlock, entry freeing, deadlines, non-blocking cross-process backoff (B7, B8, B22).
- Session arbiter: B9-B12, plus same-session-id phpts with timeouts (pm.max_children=1, shared cookie). Add a same-cookie round to the Slim suite.
- Persistent refusal for the whole executor (#550/B20).
- Non-blocking FastCGI header and body read (B15). Reproduce #549 and confirm the root cause.
- #84 gap 1,
time_sleep_until; #85 startup order; #80 deploy-root re-resolve; #551 all three batches. - Fix the vacuous and racy tests (new issue 16).
Phase 5: framework re-validation (new issues 13, 15; #449, #83, #50)
- Fix #449 on async. Re-run Symfony, Laravel and Slim on the current head (Linux, PHP 8.5.9, pm > 1, prod). Record the commit and binary with each result. Add a weekly CI job.
- Reproduce #50 with phpredis. Extend the Laravel statics audit beyond the SLEEP window (distinct marker values, fresh-process baseline).
- OPcache investigation (new issue 13).
- Measure long-run RSS (shutdown list, autoloaders) and the per-switch cost of statics (#52).
- Refresh docs/frameworks.md, fiber_errors.md and fiber_async_io.md from the new results, with a support matrix: Doctrine on MySQL works and on Postgres blocks; Guzzle with curl blocks and with streams works; Redis works without pconnect.
Phase 6: io_hooks migration (when the RFC lands; #599, #600, #81)
- Close the seam shape gaps early, so the migration becomes a backend swap.
- Replace 0007, 0008, xport, sleep and select with io_hooks. Add curl through the same seam (#81).
- Keep flock, the session arbiter and the persistent policy SAPI-side.
11. Not verified / open questions
- Nothing was reproduced at runtime. That includes B1-B3, the B7/B8 deadlocks, the B9 UAF, B10, B15, B16 and B18. Only three things were run:
vendor-php-src.sh checkon the merged tree,test-prepare-patch-stack.sh(on the test box), and the seam grep test. - Whether main's SDK build fails, or silently links the fiber/coop/async sources after a merge (B: section 2).
- Whether
GITHUB_TOKENcan push merge commits that change workflow files (U3), and whether the #578 stub fixes the 404 (first run 2026-10-04 04:41 UTC). - How long ghcr
php-fpm-ng-ci:latest(frozen at fe608da) stays available. - The root cause of #549 (blocking accept vs the gateway idle timeout).
- U1: GC-triggered destructor output going to the wrong client.
- U2: whether memcached and phpredis native session locking spin in C.
- BSD/macOS: does the header read hit EAGAIN on the inherited O_NONBLOCK and drop requests?
- How much one dropped request leaks; memory per fiber (for the #529 default).
- Whether Laravel/Symfony register shutdown functions per request, keep request state in function statics, and whether the six Laravel statics defaults are null/[] (vendor code not read).
- Whether OPcache can work without php-src changes (section 8).
- Whether the framework PASS results still hold on d7a29fd.
- How often real Symfony/Laravel traffic hits fatals (memory_limit, template errors). This sets the urgency of B1.
- Whether io_hooks exactly matches the seam shapes (checked against the #599 summary only, not the RFC text).
- Opcache JIT/preload capturing the original sleep handler addresses: moot while OPcache is refused, but it matters for the investigation.
- Whether shared
memory_limitacross fibers is acceptable, or the #529 cap must also bound memory.