Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/build-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -482,9 +482,14 @@ jobs:
# double-free, all four running nowhere until this flag appeared here.
# Debug leg only: configure calls the build unshippable, and the
# release leg is what the release resembles.
ENABLE_TEST_HOOKS: ${{ matrix.debug && '--enable-tas-test-hooks' || '' }}
# The libfiu fault points (dev/DECISIONS.md, 2026-09-30) are compiled
# in on the same leg, for the same reason: their tests skip elsewhere.
ENABLE_TEST_HOOKS: ${{ matrix.debug && '--enable-tas-test-hooks --enable-fault-injection' || '' }}
run: |
set -eux
if [ -n "$ENABLE_TEST_HOOKS" ]; then
sudo apt-get install -y --no-install-recommends libfiu-dev
fi
PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:${PKG_CONFIG_PATH:-} \
./configure \
--with-php-config=/usr/local/bin/php-config \
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Over HTTP/1 a file body that failed or came up short left the connection open, and the next response was read as the rest of the body (#362).** The static handler and `sendFile()` ignored the engine's failure status, and plaintext sendfile reported a transfer that stopped at EOF as complete; a file truncated from 2 MiB to 4 KiB under the open-file cache sent 4,096 bytes and then the `200` of the request pipelined behind it, plaintext and TLS alike, 5 runs of 5. The connection now closes after such a body, and the cache entry that sized it is dropped. A body start the event loop refused used to complete inside the parser: the static handler answered the pipelined request `400` and `sendFile()` left its own unanswered; the request is now answered whole instead. With php-async 1521d69 (true-async/php-async#308) a plaintext body of 2 MiB also reaches a macOS client whole, where it was cut at about 1 MB, and Windows reports a short transfer instead of the full length. Evidence: `h1/065` to `h1/068`.
- **A connection that arrived while the listeners were paused was answered 503 instead of waiting in the backlog (#359).** The pause at the connection cap and under CoDel calls `stop()` on each listen event, which in php-async did not stop accepting (true-async/php-async#305): the connection reached the accept callback and the hard-cap safety net refused it. `h1/062`, a third client during a pause at `setMaxConnections(2)`, read `503 Service Unavailable` on 5 of 5 runs; with php-async's fix it reads 200 after the resume, 10 of 10, as do `h1/063` (three connections at once past a cap of two) and `h1/064` (a resume that pauses again while delivering). Needs php-async with #305. `getTelemetry()` and `getStats()` gain `accepts_refused_at_cap_total`, which a pause that holds keeps at 0, and `accept_errors_total`, for accepts the reactor reported failed and the server used to drop without a trace. Under `SO_REUSEPORT` a paused worker still receives its share of new connections, which wait for it.

- **After `stop()` a connection kept serving, into a server scope `start()` had already cancelled (#345).** `stop()` released the listeners and left the connections open, and nothing that starts a request checked whether the server still ran. A keep-alive connection answered a request sent after `start()` returned, and a request in flight answered every request pipelined behind it, without `Connection: close`. Once the cancelled scope was empty php-async freed it, and the next request built its scope from freed memory: with 150 GETs pipelined behind a stuck handler, 3 to 4 runs of 30 ended in SIGSEGV. `stop()` now retires every connection: one with nothing in flight closes at once, one running a handler finishes that response with `Connection: close` and closes, the requests behind it are not run, and HTTP/2 sends GOAWAY and refuses streams opened before the peer read it. A server started again drains its handlers at the next `stop()` too; the drain ran once per object, so the second `start()` returned with its handler still running. Evidence: `core/081`, `core/082`, `h2/070` and `tls/019` fail against `48fa65b` and pass here.
Expand Down
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ set(CORE_SUBSYSTEM_SOURCES
# Reactor thread pool (issue #80): pure-C transport reactors on the ThreadPool.
src/core/reactor_pool.c
src/core/reactor_pool_test_hooks.c
# libfiu fault points under PHP control (FIU_ENABLE builds only).
src/core/fault_hooks.c
# Flat response marshalling type (issue #80, D3): worker -> reactor response.
src/core/response_wire.c
# Worker-side request dispatch (issue #80, B1b/D7): request ptr -> handler -> response.
Expand Down
23 changes: 22 additions & 1 deletion config.m4
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ PHP_ARG_ENABLE([tas-test-hooks],
[no],
[no])

PHP_ARG_ENABLE([fault-injection],
[whether to compile libfiu fault points],
[AS_HELP_STRING([--enable-fault-injection],
[Compile the libfiu fault points and the PHP functions that enable them (needs libfiu). For test/CI builds only; never enable for release.])],
[no],
[no])

PHP_ARG_WITH([openssl],
[for OpenSSL TLS support],
[AS_HELP_STRING([--with-openssl@<:@=DIR@:>@],
Expand Down Expand Up @@ -537,6 +544,7 @@ if test "$PHP_HTTP_SERVER" != "no"; then
src/core/thread_mailbox.c
src/core/reactor_pool.c
src/core/reactor_pool_test_hooks.c
src/core/fault_hooks.c
src/core/response_wire.c
src/core/worker_dispatch.c
src/core/worker_inbox.c
Expand Down Expand Up @@ -717,9 +725,22 @@ if test "$PHP_HTTP_SERVER" != "no"; then
TAS_TEST_HOOKS_FLAG="-DTAS_TEST_HOOKS=1"
fi

dnl libfiu fault points (include/fiu-local.h). Without the flag they expand to
dnl nothing and libfiu is neither needed nor linked.
FAULT_INJECTION_FLAG=""
if test "$PHP_FAULT_INJECTION" = "yes"; then
AC_CHECK_LIB([fiu], [fiu_enable], [
PHP_ADD_LIBRARY([fiu], [1], [TRUE_ASYNC_SERVER_SHARED_LIBADD])
], [
AC_MSG_ERROR([--enable-fault-injection needs libfiu. Install libfiu-dev])
])
AC_MSG_NOTICE([http_server: fault injection ENABLED — do not ship this build])
FAULT_INJECTION_FLAG="-DFIU_ENABLE=1"
fi

dnl Create extension. The trailing "cxx" arg makes the shared module link
dnl through $(CXX) so the C++ TU's runtime (libstdc++) is pulled in.
PHP_NEW_EXTENSION(true_async_server, $http_server_sources, $ext_shared,, -Wall -Wextra -Wno-unused-parameter $HTTP_SERVER_HARDENING $HTTP_SERVER_TEST_HOOKS_FLAG $TAS_TEST_HOOKS_FLAG $HTTP_SERVER_WSLAY_DEFS, cxx)
PHP_NEW_EXTENSION(true_async_server, $http_server_sources, $ext_shared,, -Wall -Wextra -Wno-unused-parameter $HTTP_SERVER_HARDENING $HTTP_SERVER_TEST_HOOKS_FLAG $TAS_TEST_HOOKS_FLAG $FAULT_INJECTION_FLAG $HTTP_SERVER_WSLAY_DEFS, cxx)
PHP_SUBST(TRUE_ASYNC_SERVER_SHARED_LIBADD)

dnl Add include paths
Expand Down
1 change: 1 addition & 0 deletions config.w32
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ if (PHP_TRUE_ASYNC_SERVER == "yes") {
"thread_mailbox.c " +
"reactor_pool.c " +
"reactor_pool_test_hooks.c " +
"fault_hooks.c " +
"response_wire.c " +
"worker_dispatch.c " +
"worker_inbox.c " +
Expand Down
6 changes: 6 additions & 0 deletions dev/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Architectural decisions, newest first. Workflow rules live in `dev/WORKFLOW.md`.

- 2026-09-30 A failed or short HTTP/1 file body ends the connection through the
framing-lost path of `http_request_finalize`; a deferred start the loop refuses
is answered before anything is written, with SEND_FILE_REFUSED.
Why: `should_continue=false` alone still served the pipelined request; closing
from a refused start ran inside `llhttp_execute` (Critic, S2).
Rejected: closing the connection on a refused start; a 500 written by the engine.
- 2026-09-30 The reactor/worker split is the second sanctioned cross-thread off-load
(CODING_STANDARDS §1.3), beside the TLS handshake.
Why: §1.3 forbade any stage crossing cores while §1.5 and the reactor pool already
Expand Down
32 changes: 20 additions & 12 deletions dev/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,22 +140,15 @@ an item marked "reproduce" gets its failing run before any code.
12. `http_server_pause_listeners` and the accept it may not stop: stage S1
above (#359, true-async/php-async#305). The accept errors it used to drop
are counted in `accept_errors_total`; they are still not logged.
12a. A failed or short HTTP/1 file body keeps the connection alive:
`h1_static_on_static_done` and `h1_sendfile_on_done` discard `status`
(`http_connection.c:2744`, `:2833`), the plaintext completion reports a
short transfer as success (`http1_sendfile.c:297-309`), and a failed defer
schedule finalizes with nothing written (`send_file.c:617`, `:758`). The
next response on the connection follows a short body, against USAGE.md:195.
Done when a file truncated mid-send, plaintext and TLS, drops keep-alive and
a pipelined second request gets no bytes glued to the first body (template:
`h3/056`). Reproduce. Found by the health check, verified by the Sage.
12a. A failed or short HTTP/1 file body kept the connection alive: done,
stage S2 (#362, true-async/php-async#308); tests `h1/065` to `h1/068`.
12b. The same on HTTP/2: a truncated or failed file body ends with END_STREAM
(`http2_static_response.c:593-603`). Done when it sends
RST_STREAM(INTERNAL_ERROR) and an h2 test that truncates a file reads error
code 2. Reproduce.
The failure in 12a and 12b comes from a libfiu fault point (`fiu_do_on`)
in the sendfile engine; installing libfiu and wiring it into the debug
build is part of 12a (`dev/DECISIONS.md`, 2026-09-30).
The failure comes from a libfiu fault point, as `h1/066` does; the build
flag and the `_http_fault_*` hooks exist (`src/core/fault_hooks.c`), and
`h1/065`'s stale-cache truncation is the template for a real short file.
13. `core/018` under load: its DEBUG run overflows the log ring and the sink's
`ring overflow, dropped=N` line on stderr fails the test. 30 of 30 runs
with six copies in parallel, on `main` and on the #313 branch alike; 8 of 8
Expand Down Expand Up @@ -243,6 +236,21 @@ Added by the health check of 2026-09-30 (`dev/HEALTH.md`), in this order:
hold up to four pending instances): all are served after the resume, in
order. The only case where php-async's delivery loop runs more than once,
so its two outcome branches have no test that kills a mutant of them.
35. A file body that failed or came up short is access-logged and counted as a
plain 200 with a short byte count (S2.3): mark it failed, as an aborted
stream is.
36. The HTTP/1 file-body phase has no write deadline: a peer that stops reading
holds the chain, and with it the connection, for good (Linux: libuv's copy
loop in `poll(-1)`; macOS: php-async's EAGAIN wait). Critic on php-async
#308, 2026-09-30. Reproduce with a client that reads 100 KiB and stalls.
37. `multipart/009` failed once on Windows (PR 363, run 36743616544, 1 of 4
runs of that branch): the third upload's `getSize()` read NULL, an upload
error. Passed on rerun and on main 8 of 8. Suspect the temporary file
(item 18's CANT_WRITE path); reproduce under `-j` on Windows.
38. A small php-async PR: regenerate `async_arginfo.h` (its stub hash is stale,
every build rewrites it), and assert in `sendfile_wait_writable` that the
destination io has no other sendfile waiting, since `sendfile_waiting`
holds one (Code Reviewer on S2, 2026-09-30).

- [ ] **Drop the streaming exemption in laravel-spawn.** `TrueAsyncServer::streamContent`
calls `setNoCompression()` on every `StreamedResponse` as the workaround for #170
Expand Down
22 changes: 22 additions & 0 deletions include/core/fault_hooks.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
+----------------------------------------------------------------------+
| Copyright (c) TrueAsync |
+----------------------------------------------------------------------+
| Licensed under the Apache License, Version 2.0 |
+----------------------------------------------------------------------+
*/

#ifndef FAULT_HOOKS_H
#define FAULT_HOOKS_H

/*
* PHP control over the libfiu fault points compiled into the extension.
* Registers `_http_fault_enable()`, `_http_fault_disable()`,
* `_http_fault_hits()` and `_http_fault_fail()` when the extension is built
* with FIU_ENABLE (--enable-fault-injection); without that flag this is a
* no-op, the points compile to nothing and no libfiu symbol is linked. Called
* unconditionally from MINIT.
*/
void fault_hooks_register(const int module_type);

#endif /* FAULT_HOOKS_H */
36 changes: 36 additions & 0 deletions include/fiu-local.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@

/* libfiu - Fault Injection in Userspace
*
* This header, part of libfiu, is meant to be included in your project to
* avoid having libfiu as a mandatory build-time dependency.
*
* You can add it to your project, and #include it instead of fiu.h.
* The real fiu.h will be used only when FIU_ENABLE is defined.
*
* This header, as the rest of libfiu, is in the public domain.
*
* You can find more information about libfiu at
* http://blitiri.com.ar/p/libfiu.
*/

#ifndef _FIU_LOCAL_H
#define _FIU_LOCAL_H

/* Only define the stubs when fiu is disabled, otherwise use the real fiu.h
* header */
#ifndef FIU_ENABLE

#define fiu_init(flags) 0
#define fiu_fail(name) 0
#define fiu_failinfo() NULL
#define fiu_do_on(name, action)
#define fiu_exit_on(name)
#define fiu_return_on(name, retval)

#else

#include <fiu.h>

#endif /* FIU_ENABLE */

#endif /* _FIU_LOCAL_H */
14 changes: 10 additions & 4 deletions include/send_file.h
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,10 @@ typedef struct
* counter). Paired with on_done. NULL = nothing to do. */
void (*on_armed)(void *user);

/* Called when delivery finishes — synchronously for the 304/error
* inline paths, or async after the protocol op. status==0 ok,
* non-zero abort. NULL = nothing to do. */
/* Called once when delivery finishes, never on send_file()'s own call
* stack: every path, the 304 and error heads included, runs from the
* deferred tick. status==0 ok; non-zero means the body failed or came up
* short of what the head declared. NULL = nothing to do. */
void (*on_done)(void *user, int status);

/* Per-protocol keep-alive verdict (H1 reads conn->keep_alive,
Expand All @@ -134,10 +135,15 @@ typedef enum
/* The file did not open under PASSTHROUGH_PHP: nothing was written and
* no callback fired; the caller runs its PHP handler. */
SEND_FILE_PASSTHROUGH = 0,
/* response_obj populated synchronously (304 or 4xx error body). */
/* The engine refused its arguments before starting (no protocol op, a
* path over MAXPATHLEN): nothing was written to response_obj and no
* callback fired; the caller answers the request itself. */
SEND_FILE_HANDLED = 1,
/* Async chain in flight; on_armed fired, on_done will fire later. */
SEND_FILE_ASYNC = 2,
/* The chain could not start (the loop refused its timer): nothing was
* written, no callback fired, the caller answers the request itself. */
SEND_FILE_REFUSED = 3,
} send_file_result_t;

send_file_result_t send_file(struct http_request_t *request, zend_object *response_obj,
Expand Down
4 changes: 4 additions & 0 deletions include/static/http_static_cache.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ typedef struct
bool http_static_cache_lookup(http_static_cache_t *cache, const char *path, size_t path_len,
http_static_cache_view_t *out_view);

/* Drop the entry for path, if any: its metadata is known not to match
* the file. No-op on a miss or a disabled cache. */
void http_static_cache_remove(http_static_cache_t *cache, const char *path, size_t path_len);

/* Insert / replace. Caller passes pre-computed metadata; the cache
* copies/owns everything. mime/etag/last_modified buffers are
* memcpy'd into the entry. content_type pointer is borrowed (it
Expand Down
Loading
Loading