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 check on the merged tree, test-prepare-patch-stack.sh on the test box, and build/test-fiber-io-seam.sh locally. 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 edited index.php, and a hand-curated fiber.isolate_statics list (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_limit per 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_function callbacks never run, and the list grows for the whole life of the process.
  • 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 session files handler 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).
  • 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.notbuilt and its orphan .h;
    • fpm_pool_fiber_validate() and fpm_coop_reval_enabled();
    • Polish log strings, and many stale docs and line references.
  • The branch cannot sync on its own. It is 13 commits ahead and 27 behind (merge base fe608da). git merge-tree reports 7 conflicting files. async-sync.yml has 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 without LOCK_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_files raw flock(2) (B8): options are a php-src patch, link-time --wrap=flock (static ext/session only), or refusing save_handler=files with pm > 1;
  • the session-lock arbiter;
  • the persistent-connection policy (#550);
  • OPcache, the per-request heap and memory_limit, per-request max_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.sh FIBER/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).

  1. fiber: a fatal error (bailout) poisons the whole child. After any bailout in fpm_coop_execute/switch_in, the GC stays protected, unclean_shutdown stays 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.
  2. fiber: SIGQUIT abandons in-flight requests. Use fpm_fiber_drain_begin() on fcgi_in_shutdown instead of breaking the loop (fpm_pool_fiber.c:447-450,469-471,590-596). The master's process_control_timeout stays the hard stop.
  3. 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. Add fpm_coop_req_abandon(), a session request_end hook, a drain deadline and a drain-after-drop phpt.
  4. fiber: request time limit and recycling. No max_execution_time, request_terminate_timeout or pm.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.
  5. 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 through wait_fd before dispatch. Meanwhile document the nginx buffering requirement.
  6. 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.
  7. 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).
  8. fiber: flock registry correctness. Release on close without LOCK_UN, free unused entries, put deadlines on WAKE, 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.
  9. 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 old mod_data on reopen.
  10. fiber: session files handler and cross-process flock(2). mod_files.c:209-211 freezes the worker; ABBA across workers (inference). Decide: php-src patch, link-time wrap, or refuse files with pm > 1.
  11. 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_startup field and whether it is swapped.
  12. 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.
  13. fiber: OPcache investigation. Test whether the forced auto-globals plus validate_timestamps=0 and revalidate drain are enough (ZendAccelerator.c:2660-2661). Fix the ce_flags check for immutable classes first.
  14. 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.
  15. 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_statics and revalidate.
  16. 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)

  1. #530: delete the async executor now, or keep it as a record. Recommended: delete.
  2. #78: the include model. Recommended: option A, a validated fiber.shared_includes directive, default on.
  3. Persistent-connection policy (#550/#50): refuse all persistent connections under fiber, regardless of can_suspend.
  4. The session files handler under fiber with pm > 1: php-src patch, link-time wrap, or refuse it (new issue 10).
  5. Whether main keeps the empty async seams (section 7) or removes them. Moving tests/frameworks and docs is already in #601/#602.
  6. 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)

  1. 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.
  2. 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).
  3. In libphp-build.sh, exclude fpm_pool_fiber*, fpm_pool_coop* and fpm_pool_async.c, or guard those files with #ifdef HAVE_FPMNG_FIBER. Add a refute_symbol check.
  4. 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.
  5. Do the manual main→async merge as a PR into async, with allow_deletions for the 8 paths. Resolve async-sync.yml with async's version.
  6. 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.
  7. 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.
  8. Fix or delete build/test-prepare-patch-stack.sh.

Phase 2: delete dead code and stale text (one PR on async; #530 if approved)

  1. Delete the async executor and its dependencies (section 4 list), if Phase 0.1 says so.
  2. Delete .notbuilt + .h, fpm_pool_fiber_validate, fpm_coop_reval_enabled, tests/statics_reference.php.
  3. Translate the Polish strings. Fix the stale header comments (section 4) and the "SPIKE" leftovers in comments.
  4. 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)

  1. Bailout → drain and exit the child (B1), with a fatal-error phpt that includes a concurrent request with __destruct and a gc_status() check.
  2. SIGQUIT → graceful drain (B2), with a reload-with-request-in-flight phpt.
  3. Dropped request: decrement in_flight, release the session lock and close mod_data, free what is safe, log, and answer 500. Add a drain deadline (B3). Extend the dropped-request phpt with a following drain.
  4. Shutdown functions per request: swap BG(user_shutdown_function_names); run and free them at the end of req_run (B4). Comment on #82.
  5. In-flight cap (#529), measured; fiber-level max requests → drain; a request watchdog outside the event loop (B13).
  6. 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)

  1. Swap the EG/PG error fields (B16). Implement the on_modify replay (#79/B17) and assert real behaviour (precision) in a phpt.
  2. Reset the isolate_statics slots at request end (B18). Decide on function statics (B19). Force max_execution_time to SYSTEM 0 if Piotr agrees (B24).
  3. 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.
  4. flock: close-without-unlock, entry freeing, deadlines, non-blocking cross-process backoff (B7, B8, B22).
  5. 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.
  6. Persistent refusal for the whole executor (#550/B20).
  7. Non-blocking FastCGI header and body read (B15). Reproduce #549 and confirm the root cause.
  8. #84 gap 1, time_sleep_until; #85 startup order; #80 deploy-root re-resolve; #551 all three batches.
  9. Fix the vacuous and racy tests (new issue 16).

Phase 5: framework re-validation (new issues 13, 15; #449, #83, #50)

  1. 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.
  2. Reproduce #50 with phpredis. Extend the Laravel statics audit beyond the SLEEP window (distinct marker values, fresh-process baseline).
  3. OPcache investigation (new issue 13).
  4. Measure long-run RSS (shutdown list, autoloaders) and the per-switch cost of statics (#52).
  5. 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)

  1. Close the seam shape gaps early, so the migration becomes a backend swap.
  2. Replace 0007, 0008, xport, sleep and select with io_hooks. Add curl through the same seam (#81).
  3. 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 check on 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_TOKEN can 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_limit across fibers is acceptable, or the #529 cap must also bound memory.