From 4f5255ee4ae8bf6fa7190ce9440af3950ae8bfa3 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:44:16 +0300 Subject: [PATCH 01/10] build: compile libfiu fault points under --enable-fault-injection, with PHP hooks to enable them (S2.1) --- .github/workflows/build-linux.yml | 7 +- CMakeLists.txt | 2 + config.m4 | 23 +- config.w32 | 1 + dev/PLAN.md | 57 +++++ include/core/fault_hooks.h | 22 ++ include/fiu-local.h | 36 +++ src/core/fault_hooks.c | 218 ++++++++++++++++++ src/http_server.c | 4 + .../server/core/083-fault-injection-hook.phpt | 47 ++++ 10 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 include/core/fault_hooks.h create mode 100644 include/fiu-local.h create mode 100644 src/core/fault_hooks.c create mode 100644 tests/phpt/server/core/083-fault-injection-hook.phpt diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index aea9888f..de64939a 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -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 \ diff --git a/CMakeLists.txt b/CMakeLists.txt index ccf9d0c5..e3af9a4b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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. diff --git a/config.m4 b/config.m4 index 007184eb..2e90c295 100644 --- a/config.m4 +++ b/config.m4 @@ -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@:>@], @@ -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 @@ -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 diff --git a/config.w32 b/config.w32 index 7e114063..df908e76 100644 --- a/config.w32 +++ b/config.w32 @@ -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 " + diff --git a/dev/PLAN.md b/dev/PLAN.md index 6711e86b..83c7e369 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -83,6 +83,63 @@ socket several workers share on Windows, an EMFILE spin, no named pipes). several held connections, which only a Windows AF_UNIX listener (pending pipe instances) produces: item 34. +## S2 — A failed or short HTTP/1 file body keeps the connection alive (item 12a) [in progress] + +Goal: a file body that fails or comes up short ends the connection, so no later +response is read as the rest of that body. +Done when: item 12a's criterion holds on Linux, macOS and Windows CI. +Tests: first +Base: eaa2d57 (server), a97cd13 (php-async) + +Critic 2026-09-30 on the plan, all taken: `should_continue=false` still serves a +pipelined request (`http_request_finalize` drains the buffer unless the framing +is lost); the defer failure finalizes inside `llhttp_execute`, so closing there +frees what the parser still reads; a plaintext truncation is absorbed by the +socket buffers, while a stale `st_size` in the open-file cache gives a +deterministic short transfer; php-async's TransmitFile counts the requested +bytes, so Windows cannot see a short transfer; libfiu's `failnum` is a return +value, not a count. + +- [x] S2.1 Fault injection in the build + done: `--enable-fault-injection` defines FIU_ENABLE and links libfiu; + `include/fiu-local.h` compiles the points to nothing without it; + `_http_fault_enable(string $name, bool $once = true)` and + `_http_fault_disable(string $name)` exist under the flag; a phpt sees an + enabled point fail and skips on a build without the flag, which has no + libfiu symbol (nm); the Linux debug CI leg installs libfiu and passes it + tier: T2 · role: Critic + Critic 2026-09-30 on the diff: a misspelt or removed point leaves a test + on the healthy path — taken: points go through fiu_enable_external and + `_http_fault_hits()` counts the failures injected. Rejected with reason: + an errno argument (no point here shims a syscall; added with the first + that does); fail-the-Nth-check (the control requests of these tests + never reach the points); clearing points at shutdown (each phpt runs in + its own process, and once-points disarm themselves); a CMake option + (the root CMakeLists builds on Windows, where libfiu does not). + handoff: `src/core/fault_hooks.c`, `core/083` passes with the flag and + skips without it; 0 `fiu_` symbols and no libfiu in ldd without it. The + CI leg's first run is part of S2.5. +- [ ] S2.2 Red tests for the three failures + done: red on `main` for the named reason: the static path with a stale + cached size, plaintext and TLS, a second request pipelined behind it; + `sendFile()` with a fault point that shortens the transfer, plaintext + and TLS; a fault point in `engine_defer_schedule` on both paths closes + the connection without a crash + tier: T1 · role: — +- [ ] S2.3 The fix in the server + done: S2.2 green; both `on_done` callbacks honour `status`; a short body + marks the framing lost; plaintext reports `bytes_sent < body_length` as + -1; a failed defer returns its own result and the caller drops + keep-alive after llhttp returns; the whole phpt suite passes + tier: T2 · role: Critic +- [ ] S2.4 php-async: TransmitFile reports the bytes it sent + done: a php-async PR merged with CI green; the server's static + short-transfer test passes on Windows against it + tier: T1 · role: — +- [ ] S2.5 Issue, CHANGELOG and the server PR + done: merged with every CI platform green + tier: T1 · role: — + ## Next ### Order of the open defects, 2026-09-28 diff --git a/include/core/fault_hooks.h b/include/core/fault_hooks.h new file mode 100644 index 00000000..315f4a44 --- /dev/null +++ b/include/core/fault_hooks.h @@ -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 */ diff --git a/include/fiu-local.h b/include/fiu-local.h new file mode 100644 index 00000000..5287ed57 --- /dev/null +++ b/include/fiu-local.h @@ -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 + +#endif /* FIU_ENABLE */ + +#endif /* _FIU_LOCAL_H */ diff --git a/src/core/fault_hooks.c b/src/core/fault_hooks.c new file mode 100644 index 00000000..c3bae799 --- /dev/null +++ b/src/core/fault_hooks.c @@ -0,0 +1,218 @@ +/* + +----------------------------------------------------------------------+ + | Copyright (c) TrueAsync | + +----------------------------------------------------------------------+ + | Licensed under the Apache License, Version 2.0 | + +----------------------------------------------------------------------+ + + Fault injection through libfiu. A fault point in C is + `fiu_do_on("area/what", action)` or `fiu_return_on(...)` from + include/fiu-local.h: without FIU_ENABLE the macros expand to nothing, so a + release build carries neither the checks nor the library. A phpt enables a + point by name, drives the server into it, reads what the peer sees, and + reads the point's hit count to prove the fault was injected at all: a + misspelt or removed point otherwise leaves the test on the healthy path. + + Points are enabled through fiu_enable_external, so this module decides each + check itself and counts it; libfiu's own FIU_ONETIME does not count. +*/ + +#ifdef HAVE_CONFIG_H +# include +#endif + +#include "core/fault_hooks.h" + +#ifdef FIU_ENABLE + +#include "php.h" +#include "Zend/zend_API.h" +#include "Zend/zend_atomic.h" +#include "zend_exceptions.h" + +#include +#include + +#define FAULT_POINTS_MAX 16 +#define FAULT_NAME_MAX 64 +#define FAULT_UNLIMITED INT_MAX + +/* One enabled point. Written by the enabling thread before + * fiu_enable_external publishes the point; afterwards only the two atomics + * change, from whichever reactor thread reaches the point. */ +typedef struct { + char name[FAULT_NAME_MAX]; + zend_atomic_int remaining; /* failures still to inject; FAULT_UNLIMITED: every check */ + zend_atomic_int hits; /* failures injected since the last enable */ +} fault_point_t; + +static fault_point_t fault_points[FAULT_POINTS_MAX]; + +/* Stored after the new entry's name is written, so a reactor thread checking + * another point never scans a half-written name. */ +static zend_atomic_int fault_points_used; + +static fault_point_t *fault_point_find(const char *name) +{ + const int used = zend_atomic_int_load(&fault_points_used); + + for (int i = 0; i < used; i++) { + if (strcmp(fault_points[i].name, name) == 0) { + return &fault_points[i]; + } + } + + return NULL; +} + +/* libfiu calls this under its read lock on every check of an enabled point; + * a non-zero answer makes fiu_fail() return failnum. */ +static int fault_point_decide(const char *name, int *failnum, void **failinfo, unsigned int *flags) +{ + (void)failnum; + (void)failinfo; + (void)flags; + fault_point_t *const point = fault_point_find(name); + + if (point == NULL) { + return 0; + } + + int left = zend_atomic_int_load(&point->remaining); + + do { + if (left == 0) { + return 0; + } + } while (left != FAULT_UNLIMITED + && !zend_atomic_int_compare_exchange(&point->remaining, &left, left - 1)); + + zend_atomic_int_inc(&point->hits); + return 1; +} + +/* Enable a fault point. With $once the point fails on its next check only; + * without it, on every check until _http_fault_disable(). Enabling a point + * again resets its hit count. Throws on an empty or over-long name, on more + * than FAULT_POINTS_MAX distinct names, and on a libfiu refusal. */ +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_fault_enable, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, once, _IS_BOOL, 0, "true") +ZEND_END_ARG_INFO() + +PHP_FUNCTION(_http_fault_enable) +{ + zend_string *name; + bool once = true; + + ZEND_PARSE_PARAMETERS_START(1, 2) + Z_PARAM_STR(name) + Z_PARAM_OPTIONAL + Z_PARAM_BOOL(once) + ZEND_PARSE_PARAMETERS_END(); + + if (ZSTR_LEN(name) == 0 || ZSTR_LEN(name) >= FAULT_NAME_MAX) { + zend_argument_value_error(1, "must be 1 to %d bytes long", FAULT_NAME_MAX - 1); + RETURN_THROWS(); + } + + fault_point_t *point = fault_point_find(ZSTR_VAL(name)); + + if (point == NULL) { + const int used = zend_atomic_int_load(&fault_points_used); + + if (used == FAULT_POINTS_MAX) { + zend_throw_error(NULL, "more than %d fault points enabled", FAULT_POINTS_MAX); + RETURN_THROWS(); + } + + point = &fault_points[used]; + memcpy(point->name, ZSTR_VAL(name), ZSTR_LEN(name) + 1); + zend_atomic_int_store(&fault_points_used, used + 1); + } + + zend_atomic_int_store(&point->remaining, once ? 1 : FAULT_UNLIMITED); + zend_atomic_int_store(&point->hits, 0); + + if (fiu_enable_external(ZSTR_VAL(name), 1, NULL, 0, fault_point_decide) != 0) { + zend_throw_error(NULL, "fiu_enable_external(\"%s\") failed", ZSTR_VAL(name)); + RETURN_THROWS(); + } +} + +/* Disable a fault point; its hit count stays readable. Disabling a point + * that is not enabled is not an error. */ +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_fault_disable, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_FUNCTION(_http_fault_disable) +{ + zend_string *name; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STR(name) + ZEND_PARSE_PARAMETERS_END(); + + (void)fiu_disable(ZSTR_VAL(name)); +} + +/* How many failures the point injected since it was last enabled; 0 for a + * name never enabled. */ +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_fault_hits, 0, 1, IS_LONG, 0) + ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_FUNCTION(_http_fault_hits) +{ + zend_string *name; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STR(name) + ZEND_PARSE_PARAMETERS_END(); + + fault_point_t *const point = fault_point_find(ZSTR_VAL(name)); + RETURN_LONG(point != NULL ? zend_atomic_int_load(&point->hits) : 0); +} + +/* Check a point the way a fault point in C does. Exists so a test can check + * the injection itself on a known answer. */ +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_fault_fail, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, name, IS_STRING, 0) +ZEND_END_ARG_INFO() + +PHP_FUNCTION(_http_fault_fail) +{ + zend_string *name; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STR(name) + ZEND_PARSE_PARAMETERS_END(); + + RETURN_BOOL(fiu_fail(ZSTR_VAL(name)) != 0); +} + +static const zend_function_entry fault_hook_functions[] = { + ZEND_FE(_http_fault_enable, arginfo_fault_enable) + ZEND_FE(_http_fault_disable, arginfo_fault_disable) + ZEND_FE(_http_fault_hits, arginfo_fault_hits) + ZEND_FE(_http_fault_fail, arginfo_fault_fail) + PHP_FE_END +}; + +void fault_hooks_register(const int module_type) +{ + /* fiu_init only allocates the point table; with no point enabled every + * fiu_fail() answers 0, so the points stay inert until a test asks. */ + (void)fiu_init(0); + zend_register_functions(NULL, fault_hook_functions, NULL, module_type); +} + +#else /* !FIU_ENABLE */ + +void fault_hooks_register(const int module_type) +{ + (void)module_type; +} + +#endif /* FIU_ENABLE */ diff --git a/src/http_server.c b/src/http_server.c index 5502b3b7..134f3cc6 100644 --- a/src/http_server.c +++ b/src/http_server.c @@ -23,6 +23,7 @@ #endif #include "http_known_strings.h" #include "core/reactor_pool_test.h" +#include "core/fault_hooks.h" #include "log/http_log.h" #include "static/static_handler.h" #include "http_send_file.h" @@ -224,6 +225,9 @@ PHP_MINIT_FUNCTION(http_server) /* Reliable-room fault-injection hook; a no-op unless built with TAS_TEST_HOOKS. */ room_hub_test_register(type); + /* libfiu fault points under PHP control; a no-op unless built with FIU_ENABLE. */ + fault_hooks_register(type); + return SUCCESS; } /* }}} */ diff --git a/tests/phpt/server/core/083-fault-injection-hook.phpt b/tests/phpt/server/core/083-fault-injection-hook.phpt new file mode 100644 index 00000000..b3b1901c --- /dev/null +++ b/tests/phpt/server/core/083-fault-injection-hook.phpt @@ -0,0 +1,47 @@ +--TEST-- +HttpServer: libfiu fault points are enabled by name, once or until disabled +--EXTENSIONS-- +true_async_server +--SKIPIF-- + +--FILE-- +getMessage(), "\n"; +} +?> +--EXPECT-- +bool(false) +bool(true) +bool(false) +int(1) +bool(true) +bool(true) +bool(true) +bool(false) +int(3) +int(0) +_http_fault_enable(): Argument #1 ($name) must be 1 to 63 bytes long From 3d30e901c2da189638e6efa60f1dc5addb83f81d Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:52:59 +0300 Subject: [PATCH 02/10] test(h1): reproduce a short or failed file body that keeps the connection alive (S2.2) --- dev/PLAN.md | 9 +- src/http1/http1_sendfile.c | 8 +- src/send_file.c | 4 + .../h1/065-h1-static-short-body-closes.phpt | 90 +++++++++++++++++++ .../h1/066-h1-sendfile-io-error-closes.phpt | 80 +++++++++++++++++ .../h1/067-h1-file-body-defer-failure.phpt | 70 +++++++++++++++ tests/phpt/server/h1/_pipeline_probe.inc | 80 +++++++++++++++++ 7 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 tests/phpt/server/h1/065-h1-static-short-body-closes.phpt create mode 100644 tests/phpt/server/h1/066-h1-sendfile-io-error-closes.phpt create mode 100644 tests/phpt/server/h1/067-h1-file-body-defer-failure.phpt create mode 100644 tests/phpt/server/h1/_pipeline_probe.inc diff --git a/dev/PLAN.md b/dev/PLAN.md index 83c7e369..5add063a 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -119,13 +119,20 @@ value, not a count. handoff: `src/core/fault_hooks.c`, `core/083` passes with the flag and skips without it; 0 `fiu_` symbols and no libfiu in ldd without it. The CI leg's first run is part of S2.5. -- [ ] S2.2 Red tests for the three failures +- [x] S2.2 Red tests for the three failures done: red on `main` for the named reason: the static path with a stale cached size, plaintext and TLS, a second request pipelined behind it; `sendFile()` with a fault point that shortens the transfer, plaintext and TLS; a fault point in `engine_defer_schedule` on both paths closes the connection without a crash tier: T1 · role: — + handoff: `h1/065` (stale cache), `066` (point `h1/file_body/io_error`), + `067` (point `send_file/defer_schedule`), client `h1/_pipeline_probe.inc`. + Red 5 of 5 (065) and 3 of 3 (066, 067): the pipelined 200 is glued + after the short body; on a failed defer the static path answers the + pipelined request 400 and sendFile() leaves /f unanswered. The defer + design changed: the schedule moves before on_armed, and a failure + returns SEND_FILE_HANDLED, which both callers already answer. - [ ] S2.3 The fix in the server done: S2.2 green; both `on_done` callbacks honour `status`; a short body marks the framing lost; plaintext reports `bytes_sent < body_length` as diff --git a/src/http1/http1_sendfile.c b/src/http1/http1_sendfile.c index 88fecca8..f9c6335d 100644 --- a/src/http1/http1_sendfile.c +++ b/src/http1/http1_sendfile.c @@ -54,6 +54,7 @@ #include "core/tls_layer.h" #endif #include "http1/http1_sendfile.h" +#include "fiu-local.h" #include #ifdef __linux__ @@ -302,7 +303,8 @@ static void h1_send_dispatch(zend_async_event_t *event, req->dispose(req); } - if (UNEXPECTED(exception != NULL)) { + /* Fault point: the transfer ended in an I/O error. */ + if (UNEXPECTED(exception != NULL) || fiu_fail("h1/file_body/io_error")) { state->status = -1; } @@ -317,7 +319,9 @@ static void h1_send_dispatch(zend_async_event_t *event, state->pending_req = NULL; const ssize_t got = req->transferred; - const bool err = (exception != NULL || req->exception != NULL); + /* Fault point: the file read ended in an I/O error. */ + const bool err = (exception != NULL || req->exception != NULL + || fiu_fail("h1/file_body/io_error")); if (req->exception != NULL) { OBJ_RELEASE(req->exception); diff --git a/src/send_file.c b/src/send_file.c index d931bf24..f0e5df88 100644 --- a/src/send_file.c +++ b/src/send_file.c @@ -32,6 +32,7 @@ #include "static/static_handler.h" /* http_static_cache_acquire decl */ #include "static/http_static_cache.h" #include "log/http_log.h" /* access-log emit */ +#include "fiu-local.h" #include #ifdef PHP_WIN32 @@ -563,6 +564,9 @@ static void engine_defer_dispatch(zend_async_event_t *event, zend_async_event_ca * request dispatcher on send_file()'s synchronous call stack. */ static bool engine_defer_schedule(engine_state_t *state) { + /* Fault point: the loop cannot take the timer. */ + fiu_return_on("send_file/defer_schedule", false); + zend_async_timer_event_t *timer = ZEND_ASYNC_NEW_TIMER_EVENT(0, false); if (UNEXPECTED(timer == NULL)) { diff --git a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt new file mode 100644 index 00000000..b493c396 --- /dev/null +++ b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt @@ -0,0 +1,90 @@ +--TEST-- +HttpServer: a static file body cut short closes the connection instead of serving the pipelined request (plaintext and TLS) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'proc_open' => true, 'php_ssl' => true]); +?> +--FILE-- +addListener('127.0.0.1', $port, $scheme === 'ssl') + ->setReadTimeout(10)->setWriteTimeout(10); + + if ($scheme === 'ssl') { + $config->enableTls(true)->setCertificate($cert)->setPrivateKey($key); + } + + $server = new HttpServer($config); + $server->addStaticHandler((new StaticHandler('/s/', $root))->setOpenFileCache(16, 60)); + + $client = spawn(function () use ($server, $scheme, $port, $file) { + usleep(100000); + /* Fills the cache with the 2 MiB size. */ + $warm = h1_pipeline_probe($scheme, $port, '/s/small.txt', '/s/big.bin'); + echo "$scheme warm: ", str_contains($warm, 'extra=200') ? 'served' : $warm, "\n"; + + $fp = fopen($file, 'r+'); + ftruncate($fp, 4096); + fclose($fp); + clearstatcache(); + + echo "$scheme short: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + $server->stop(); + }); + + spawn(function () use ($server) { + usleep(15000000); + if ($server->isRunning()) { $server->stop(); } + }); + + $server->start(); + await($client); +} +?> +--EXPECT-- +tcp warm: served +tcp short: status=200 declared=2097152 got=4096 extra=none end=closed +ssl warm: served +ssl short: status=200 declared=2097152 got=4096 extra=none end=closed diff --git a/tests/phpt/server/h1/066-h1-sendfile-io-error-closes.phpt b/tests/phpt/server/h1/066-h1-sendfile-io-error-closes.phpt new file mode 100644 index 00000000..369f6990 --- /dev/null +++ b/tests/phpt/server/h1/066-h1-sendfile-io-error-closes.phpt @@ -0,0 +1,80 @@ +--TEST-- +HttpServer: a sendFile() body that ends in an I/O error closes the connection instead of serving the pipelined request (plaintext and TLS) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'proc_open' => true, 'php_ssl' => true]); +?> +--FILE-- +addListener('127.0.0.1', $port, $scheme === 'ssl') + ->setReadTimeout(10)->setWriteTimeout(10); + + if ($scheme === 'ssl') { + $config->enableTls(true)->setCertificate($cert)->setPrivateKey($key); + } + + $server = new HttpServer($config); + $server->addHttpHandler(function ($req, $res) use ($file) { + $req->getPath() === '/f' ? $res->sendFile($file) : $res->setBody('small'); + }); + + $client = spawn(function () use ($server, $scheme, $port, $point) { + usleep(100000); + _http_fault_enable($point); + echo "$scheme: ", h1_pipeline_probe($scheme, $port, '/f', '/small'); + echo "$scheme hits: ", _http_fault_hits($point), "\n"; + $server->stop(); + }); + + spawn(function () use ($server) { + usleep(15000000); + if ($server->isRunning()) { $server->stop(); } + }); + + $server->start(); + await($client); +} +?> +--EXPECT-- +tcp: status=200 declared=2097152 got=2097152 extra=none end=closed +tcp hits: 1 +ssl: status=200 declared=2097152 got=0 extra=none end=closed +ssl hits: 1 diff --git a/tests/phpt/server/h1/067-h1-file-body-defer-failure.phpt b/tests/phpt/server/h1/067-h1-file-body-defer-failure.phpt new file mode 100644 index 00000000..56315d79 --- /dev/null +++ b/tests/phpt/server/h1/067-h1-file-body-defer-failure.phpt @@ -0,0 +1,70 @@ +--TEST-- +HttpServer: a file body whose deferred start cannot be scheduled is answered, not left hanging (static and sendFile) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(10)->setWriteTimeout(10)); +$server->addStaticHandler(new StaticHandler('/s/', $root)); +$server->addHttpHandler(function ($req, $res) use ($file) { + $req->getPath() === '/f' ? $res->sendFile($file) : $res->setBody('small'); +}); + +$client = spawn(function () use ($server, $port, $point) { + usleep(100000); + + foreach (['static' => '/s/big.bin', 'sendFile' => '/f'] as $name => $path) { + _http_fault_enable($point); + echo "$name: ", h1_pipeline_probe('tcp', $port, $path, '/small'); + echo "$name hits: ", _http_fault_hits($point), "\n"; + } + + $server->stop(); +}); + +spawn(function () use ($server) { + usleep(15000000); + if ($server->isRunning()) { $server->stop(); } +}); + +$server->start(); +await($client); +?> +--EXPECTF-- +static: status=200 declared=2097152 got=2097152 extra=200 end=closed +static hits: 1 +sendFile: status=500 declared=%d got=%d extra=200 end=closed +sendFile hits: 1 diff --git a/tests/phpt/server/h1/_pipeline_probe.inc b/tests/phpt/server/h1/_pipeline_probe.inc new file mode 100644 index 00000000..5166bb9b --- /dev/null +++ b/tests/phpt/server/h1/_pipeline_probe.inc @@ -0,0 +1,80 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + $out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($proc); + + return $out; +} + +if (realpath($_SERVER['argv'][0] ?? '') !== __FILE__) { + return; +} + +[, $scheme, $port, $first, $second] = $argv; + +$ctx = stream_context_create(['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]); +$c = stream_socket_client("$scheme://127.0.0.1:$port", $errno, $errstr, 4, STREAM_CLIENT_CONNECT, $ctx); + +if ($c === false) { + echo "connect failed: $errstr\n"; + exit(1); +} + +fwrite($c, "GET $first HTTP/1.1\r\nHost: t\r\n\r\n" + . "GET $second HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n"); + +stream_set_timeout($c, 4); +$in = ''; + +while (!feof($c)) { + $chunk = fread($c, 65536); + + if ($chunk === false || $chunk === '') { + if (stream_get_meta_data($c)['timed_out']) { + break; + } + + continue; + } + + $in .= $chunk; +} + +$end = feof($c) ? 'closed' : 'open'; +$head_end = strpos($in, "\r\n\r\n"); + +if ($head_end === false) { + echo "status=none got=0 extra=none end=$end\n"; + exit(0); +} + +$head = substr($in, 0, $head_end); +$rest = substr($in, $head_end + 4); +preg_match('~^HTTP/1\.1 (\d+)~', $head, $status); +preg_match('~\r\ncontent-length: *(\d+)~i', $head, $declared); + +$next = strpos($rest, "HTTP/1.1 "); +$got = $next === false ? strlen($rest) : $next; +$extra = $next === false ? 'none' : substr($rest, $next + 9, 3); + +printf("status=%s declared=%s got=%d extra=%s end=%s\n", + $status[1] ?? '?', $declared[1] ?? '?', $got, $extra, $end); From 86111c75672c6c4306d494aa0ce15e1bd4a6e2ac Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:06:17 +0300 Subject: [PATCH 03/10] fix(h1): end the connection after a failed or short file body, and answer a file request whose deferred start is refused (S2.3) --- dev/PLAN.md | 19 +++- include/send_file.h | 14 ++- include/static/http_static_cache.h | 4 + src/core/http_connection.c | 32 ++++-- src/core/http_connection.h | 4 + src/http1/http1_sendfile.c | 9 +- src/http_send_file.c | 3 +- src/send_file.c | 104 +++++++++++++----- src/static/http_static.c | 5 +- src/static/http_static_cache.c | 16 +++ .../h1/065-h1-static-short-body-closes.phpt | 7 +- 11 files changed, 166 insertions(+), 51 deletions(-) diff --git a/dev/PLAN.md b/dev/PLAN.md index 5add063a..07813c5c 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -133,12 +133,26 @@ value, not a count. pipelined request 400 and sendFile() leaves /f unanswered. The defer design changed: the schedule moves before on_armed, and a failure returns SEND_FILE_HANDLED, which both callers already answer. -- [ ] S2.3 The fix in the server +- [x] S2.3 The fix in the server done: S2.2 green; both `on_done` callbacks honour `status`; a short body marks the framing lost; plaintext reports `bytes_sent < body_length` as -1; a failed defer returns its own result and the caller drops keep-alive after llhttp returns; the whole phpt suite passes tier: T2 · role: Critic + Critic 2026-09-30 on the diff, taken: a real timer refusal raises an + exception the fault point did not, and a pending one stops the + fallback answer — cleared in `engine_defer_schedule`, the point now + throws the same way; a stale cache entry failed every request until its + TTL — evicted on a non-zero status (`http_static_cache_remove`); the + `SEND_FILE_HANDLED` and `on_done` docs were wrong — rewritten. Known: + Windows needs S2.4. Rejected: the synchronous fallback slurping a large + file after a refusal (the existing path for every refusal, not this + change's); the access log showing a short body as a plain 200 (item 35). + handoff: the defer failure keeps keep-alive, unlike the done line: the + timer is taken before on_armed and a refusal returns SEND_FILE_REFUSED, + which both callers answer whole (S2.2). 065, 066, 067 green; with the + exception clear or the eviction removed, 067 or 065 fails. Suite 520 of + 545 passed, 25 skipped, 0 failed. - [ ] S2.4 php-async: TransmitFile reports the bytes it sent done: a php-async PR merged with CI green; the server's static short-transfer test passes on Windows against it @@ -307,6 +321,9 @@ 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. - [ ] **Drop the streaming exemption in laravel-spawn.** `TrueAsyncServer::streamContent` calls `setNoCompression()` on every `StreamedResponse` as the workaround for #170 diff --git a/include/send_file.h b/include/send_file.h index 7edf7025..31e083f5 100644 --- a/include/send_file.h +++ b/include/send_file.h @@ -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, @@ -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, diff --git a/include/static/http_static_cache.h b/include/static/http_static_cache.h index 4dbeae86..a7c9631b 100644 --- a/include/static/http_static_cache.h +++ b/include/static/http_static_cache.h @@ -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 diff --git a/src/core/http_connection.c b/src/core/http_connection.c index 234b0c0b..7f6b8ae0 100644 --- a/src/core/http_connection.c +++ b/src/core/http_connection.c @@ -2739,9 +2739,21 @@ static void h1_static_on_hard_zero_armed(void *user) http_server_on_request_dispatch(conn->counters); } +/* The keep-alive verdict after a file body. A failed or short body has + * promised the peer bytes it will not send, so the connection ends with it. */ +static bool h1_file_body_continue(http1_request_ctx_t *ctx, const int status, + const bool should_continue) +{ + if (status == 0) { + return should_continue; + } + + ctx->file_body_failed = true; + return false; +} + static void h1_static_on_static_done(void *user, int status) { - (void)status; http1_request_ctx_t *ctx = (http1_request_ctx_t *)user; http_connection_t *conn = ctx->conn; @@ -2756,7 +2768,7 @@ static void h1_static_on_static_done(void *user, int status) conn->current_request = NULL; } - const bool should_continue = conn->keep_alive != 0; + const bool should_continue = h1_file_body_continue(ctx, status, conn->keep_alive != 0); http_request_finalize(conn, ctx, should_continue); } @@ -2787,11 +2799,10 @@ typedef struct { static void h1_sendfile_on_done(void *user, int status) { - (void)status; h1_sendfile_user_t *u = (h1_sendfile_user_t *)user; http_connection_t *conn = u->conn; http1_request_ctx_t *ctx = u->ctx; - const bool should_continue = u->should_continue; + const bool should_continue = h1_file_body_continue(ctx, status, u->should_continue); efree(u); http_request_finalize(conn, ctx, should_continue); @@ -3539,12 +3550,13 @@ void http_request_finalize(http_connection_t *conn, http1_request_ctx_t *ctx, conn->counters, conn->log_state); } - /* Two ways a connection is left with no boundary the peer can find, and - * nothing more may be written to it in either. A chunked body that stopped - * short of its terminator is one. A body the close delimits is the other: - * there the peer reads to EOF, so a second response would arrive as the - * first one's last bytes. Read below, before ctx is freed. */ - const bool framing_lost = ctx->stream_dead || ctx->close_delimited; + /* Three ways a connection is left with no boundary the peer can find, and + * nothing more may be written to it in any. A chunked body that stopped + * short of its terminator is one, and a file body short of its + * Content-Length is its fixed-length twin. A body the close delimits is the + * last: there the peer reads to EOF, so a second response would arrive as + * the first one's last bytes. Read below, before ctx is freed. */ + const bool framing_lost = ctx->stream_dead || ctx->close_delimited || ctx->file_body_failed; /* Tear down per-request state. Zvals + ctx are owned solely by * this finalize; no other path looks at them after the caller diff --git a/src/core/http_connection.h b/src/core/http_connection.h index 9ec65a77..9f622eb3 100644 --- a/src/core/http_connection.h +++ b/src/core/http_connection.h @@ -410,6 +410,10 @@ typedef struct { * connection close and by nothing else * (H1_FRAMING_CLOSE), so no message may * follow it on this connection. */ + bool file_body_failed; /* a file body ended in an error or short + * of its Content-Length: the peer still + * counts down bytes that will not come, + * so no message may follow it either. */ bool skip_php_handler; /* static handler (issue #13) populated the * response in C; coroutine entry must NOT * call the user PHP handler. Dispose still diff --git a/src/http1/http1_sendfile.c b/src/http1/http1_sendfile.c index f9c6335d..bc004864 100644 --- a/src/http1/http1_sendfile.c +++ b/src/http1/http1_sendfile.c @@ -352,10 +352,15 @@ static void h1_send_dispatch(zend_async_event_t *event, static void h1_send_handle_sendfile_done(h1_send_state_t *state) { + /* sendfile stops at EOF without an error, so a file that shrank after its + * size was taken shows only as a short count. */ + if (state->bytes_sent < state->body_length) { + state->status = -1; + } + /* Body sent (or partially sent on error). On error we still * finalize — bytes already on the wire are out of our control; - * the keep-alive verdict the static handler set decides what - * happens to the connection next. */ + * on_done ends the connection on a non-zero status. */ h1_send_finalize(state); } diff --git a/src/http_send_file.c b/src/http_send_file.c index 3372e362..267d4bf0 100644 --- a/src/http_send_file.c +++ b/src/http_send_file.c @@ -225,7 +225,8 @@ bool http_send_file_dispatch(http_request_t *request, zend_object *response_obj, } /* Engine refused before kick-off (MAXPATHLEN, FS_OPEN failure, - * cb alloc). adapter wasn't handed off; on_done not fired. */ + * cb alloc, no timer for the deferred start). adapter wasn't handed + * off; on_done not fired. */ sf_adapter_free(adapter); if (r != SEND_FILE_HANDLED) { diff --git a/src/send_file.c b/src/send_file.c index f0e5df88..d8b1d96c 100644 --- a/src/send_file.c +++ b/src/send_file.c @@ -33,6 +33,7 @@ #include "static/http_static_cache.h" #include "log/http_log.h" /* access-log emit */ #include "fiu-local.h" +#include "zend_exceptions.h" #include #ifdef PHP_WIN32 @@ -157,6 +158,17 @@ static bool engine_resolve_content_type(const engine_state_t *state, const char return http_mime_lookup_by_ext(state->fs_path, state->fs_path_len, out, out_len); } +/* The open-file cache this request reads and fills: the mount's own, else + * the server's; NULL when neither is configured. */ +static http_static_cache_t *engine_cache(const send_file_config_t *cfg) +{ + if (cfg->cache != NULL) { + return cfg->cache; + } + + return cfg->server != NULL ? http_static_cache_acquire(cfg->server) : NULL; +} + static void engine_on_protocol_done(void *user, int status); static void engine_defer_cleanup(engine_state_t *state); @@ -173,6 +185,16 @@ static void engine_finalize(engine_state_t *state, int status) state->file_io = NULL; } + /* A failed body may have been sized from a cache entry the file no longer + * matches; left in place it would fail every request until its TTL. */ + if (status != 0 && state->fs_path != NULL) { + http_static_cache_t *const cache = engine_cache(&state->cfg); + + if (cache != NULL) { + http_static_cache_remove(cache, state->fs_path, state->fs_path_len); + } + } + if (state->cfg.delete_after_send && status == 0 && state->fs_path != NULL) { #ifdef PHP_WIN32 (void)_unlink(state->fs_path); @@ -317,9 +339,8 @@ static void engine_handle_stat(engine_state_t *state) /* === Cache insert (only on miss path) =========================== */ - if (view == NULL && (cfg->cache != NULL || cfg->server != NULL)) { - http_static_cache_t *const cache = - cfg->cache != NULL ? cfg->cache : http_static_cache_acquire(cfg->server); + if (view == NULL) { + http_static_cache_t *const cache = engine_cache(cfg); if (cache != NULL) { http_static_cache_insert(cache, state->fs_path, state->fs_path_len, &state->st, @@ -562,13 +583,21 @@ static void engine_defer_dispatch(zend_async_event_t *event, zend_async_event_ca /* Defer onto the next loop tick so on_done never re-enters the * request dispatcher on send_file()'s synchronous call stack. */ -static bool engine_defer_schedule(engine_state_t *state) +static bool engine_defer_schedule_timer(engine_state_t *state) { - /* Fault point: the loop cannot take the timer. */ - fiu_return_on("send_file/defer_schedule", false); - zend_async_timer_event_t *timer = ZEND_ASYNC_NEW_TIMER_EVENT(0, false); + /* Fault point: the loop refuses the timer the way php-async does, with + * NULL and an exception raised. */ + fiu_do_on("send_file/defer_schedule", { + if (timer != NULL) { + timer->base.dispose(&timer->base); + } + + zend_throw_error(NULL, "Failed to initialize timer handle: fault point"); + timer = NULL; + }); + if (UNEXPECTED(timer == NULL)) { return false; } @@ -601,28 +630,54 @@ static bool engine_defer_schedule(engine_state_t *state) return true; } -/* Schedule a deferred error emission via the protocol op. on_armed - * fires synchronously here (caller pins resources), the actual head - * is written on the next loop tick. */ -static send_file_result_t engine_arm_and_defer_error(engine_state_t *state, int status, - const char *body, size_t body_len) +static bool engine_defer_schedule(engine_state_t *state) +{ + if (EXPECTED(engine_defer_schedule_timer(state))) { + return true; + } + + /* php-async raises an exception with each refusal. The caller answers + * the request itself after a refusal, and a pending exception would stop + * that answer: no handler coroutine is spawned while one is set. */ + if (EG(exception) != NULL) { + zend_clear_exception(); + } + + return false; +} + +/* Defer the rest of the chain onto the next loop tick and arm the caller. + * The timer is taken first: a loop that cannot take one leaves the caller + * unarmed and nothing written, free to answer the request itself, where a + * failure after on_armed would complete the request inside the caller's + * synchronous frame (the HTTP/1 parser's, for the static handler). */ +static send_file_result_t engine_arm_and_defer(engine_state_t *state) { + if (UNEXPECTED(!engine_defer_schedule(state))) { + engine_finalize(state, -1); + return SEND_FILE_REFUSED; + } + if (state->cbs.on_armed != NULL) { state->cbs.on_armed(state->user); } state->armed = true; + return SEND_FILE_ASYNC; +} + +/* Schedule a deferred error emission via the protocol op. on_armed + * fires synchronously here (caller pins resources), the actual head + * is written on the next loop tick. */ +static send_file_result_t engine_arm_and_defer_error(engine_state_t *state, int status, + const char *body, size_t body_len) +{ state->defer_emit_error = true; state->defer_status = status; state->defer_body = body; state->defer_body_len = body_len; - if (UNEXPECTED(!engine_defer_schedule(state))) { - engine_finalize(state, -1); - return SEND_FILE_ASYNC; - } - - return SEND_FILE_ASYNC; + return engine_arm_and_defer(state); } /* PASSTHROUGH_PHP fires sync — caller switches paths on the return @@ -746,18 +801,7 @@ send_file_result_t send_file(struct http_request_t *request, zend_object *respon return engine_arm_and_defer_error(state, 500, "Internal Server Error", 21); } - if (state->cbs.on_armed != NULL) { - state->cbs.on_armed(user); - } - - state->armed = true; - - if (UNEXPECTED(!engine_defer_schedule(state))) { - engine_finalize(state, -1); - return SEND_FILE_ASYNC; - } - - return SEND_FILE_ASYNC; + return engine_arm_and_defer(state); } zend_string *fs_slurp_fd(const int fd, const size_t expected_size) diff --git a/src/static/http_static.c b/src/static/http_static.c index d08015f8..563bc21d 100644 --- a/src/static/http_static.c +++ b/src/static/http_static.c @@ -515,8 +515,9 @@ http_static_result_t http_static_try_serve_mounts( if (r == SEND_FILE_PASSTHROUGH) { return HTTP_STATIC_PASSTHROUGH; } - /* SEND_FILE_HANDLED — engine refused before kick-off (rare: - * MAXPATHLEN, ZEND_ASYNC_FS_OPEN failure, ecalloc failure). + /* SEND_FILE_HANDLED or SEND_FILE_REFUSED — engine refused + * before kick-off (rare: MAXPATHLEN, ZEND_ASYNC_FS_OPEN + * failure, ecalloc failure, no timer for the deferred start). * Fall through to the synchronous fallback path below. */ } diff --git a/src/static/http_static_cache.c b/src/static/http_static_cache.c index 8b0d344d..880cdea9 100644 --- a/src/static/http_static_cache.c +++ b/src/static/http_static_cache.c @@ -276,6 +276,22 @@ bool http_static_cache_lookup(http_static_cache_t *cache, const char *path, size return true; } +void http_static_cache_remove(http_static_cache_t *cache, const char *path, size_t path_len) +{ + ZEND_ASSERT(cache != NULL); + ZEND_ASSERT(path != NULL); + + if (cache_disabled(cache, path_len)) { + return; + } + + entry_t *const e = (entry_t *)zend_hash_str_find_ptr(&cache->index, path, path_len); + + if (e != NULL) { + evict_entry(cache, e); + } +} + void http_static_cache_insert(http_static_cache_t *cache, const char *path, size_t path_len, const zend_stat_t *st, const char *content_type, size_t content_type_len, const char *etag, size_t etag_len, diff --git a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt index b493c396..489e65a9 100644 --- a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt +++ b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt @@ -15,7 +15,8 @@ tls_skipif(['openssl_cli' => true, 'proc_open' => true, 'php_ssl' => true]); * 4 KiB: plaintext sendfile returns 0, the TLS read returns 0. The peer is * then owed 2 MiB - 4 KiB bytes it will never get, so anything the server * writes next on the connection is read as body. The connection has to close - * after the short body; the request pipelined behind it goes unanswered. */ + * after the short body; the request pipelined behind it goes unanswered, and + * the next request for the file is sized from the file again. */ require_once __DIR__ . '/../tls/_tls_skipif.inc'; require_once __DIR__ . '/../_free_port.inc'; @@ -71,6 +72,8 @@ foreach (['tcp', 'ssl'] as $scheme) { clearstatcache(); echo "$scheme short: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + /* The failure evicted the stale entry, so the size is taken afresh. */ + echo "$scheme after: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); $server->stop(); }); @@ -86,5 +89,7 @@ foreach (['tcp', 'ssl'] as $scheme) { --EXPECT-- tcp warm: served tcp short: status=200 declared=2097152 got=4096 extra=none end=closed +tcp after: status=200 declared=4096 got=4096 extra=200 end=closed ssl warm: served ssl short: status=200 declared=2097152 got=4096 extra=none end=closed +ssl after: status=200 declared=4096 got=4096 extra=200 end=closed From f35d024cb56332076fb2695d93b44b330d31ee1c Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:39:46 +0300 Subject: [PATCH 04/10] test(h1): run the plaintext short-body case apart from TLS, and print the warm-up transfer (#362) --- CHANGELOG.md | 1 + dev/DECISIONS.md | 6 ++ dev/PLAN.md | 33 +------ .../h1/065-h1-static-short-body-closes.phpt | 29 ++---- .../068-h1-tls-static-short-body-closes.phpt | 91 +++++++++++++++++++ 5 files changed, 108 insertions(+), 52 deletions(-) create mode 100644 tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 64dd2fa2..e768b558 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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. diff --git a/dev/DECISIONS.md b/dev/DECISIONS.md index 30aaa276..fe19805e 100644 --- a/dev/DECISIONS.md +++ b/dev/DECISIONS.md @@ -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 diff --git a/dev/PLAN.md b/dev/PLAN.md index 07813c5c..aedbc93c 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -101,38 +101,9 @@ bytes, so Windows cannot see a short transfer; libfiu's `failnum` is a return value, not a count. - [x] S2.1 Fault injection in the build - done: `--enable-fault-injection` defines FIU_ENABLE and links libfiu; - `include/fiu-local.h` compiles the points to nothing without it; - `_http_fault_enable(string $name, bool $once = true)` and - `_http_fault_disable(string $name)` exist under the flag; a phpt sees an - enabled point fail and skips on a build without the flag, which has no - libfiu symbol (nm); the Linux debug CI leg installs libfiu and passes it - tier: T2 · role: Critic - Critic 2026-09-30 on the diff: a misspelt or removed point leaves a test - on the healthy path — taken: points go through fiu_enable_external and - `_http_fault_hits()` counts the failures injected. Rejected with reason: - an errno argument (no point here shims a syscall; added with the first - that does); fail-the-Nth-check (the control requests of these tests - never reach the points); clearing points at shutdown (each phpt runs in - its own process, and once-points disarm themselves); a CMake option - (the root CMakeLists builds on Windows, where libfiu does not). - handoff: `src/core/fault_hooks.c`, `core/083` passes with the flag and - skips without it; 0 `fiu_` symbols and no libfiu in ldd without it. The - CI leg's first run is part of S2.5. + handoff: `src/core/fault_hooks.c`; `core/083` passes with the flag, skips without. - [x] S2.2 Red tests for the three failures - done: red on `main` for the named reason: the static path with a stale - cached size, plaintext and TLS, a second request pipelined behind it; - `sendFile()` with a fault point that shortens the transfer, plaintext - and TLS; a fault point in `engine_defer_schedule` on both paths closes - the connection without a crash - tier: T1 · role: — - handoff: `h1/065` (stale cache), `066` (point `h1/file_body/io_error`), - `067` (point `send_file/defer_schedule`), client `h1/_pipeline_probe.inc`. - Red 5 of 5 (065) and 3 of 3 (066, 067): the pipelined 200 is glued - after the short body; on a failed defer the static path answers the - pipelined request 400 and sendFile() leaves /f unanswered. The defer - design changed: the schedule moves before on_armed, and a failure - returns SEND_FILE_HANDLED, which both callers already answer. + handoff: `h1/065`, `066`, `067`, red 5 of 5 and 3 of 3 on eaa2d57. - [x] S2.3 The fix in the server done: S2.2 green; both `on_done` callbacks honour `status`; a short body marks the framing lost; plaintext reports `bytes_sent < body_length` as diff --git a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt index 489e65a9..3e864184 100644 --- a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt +++ b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt @@ -1,24 +1,22 @@ --TEST-- -HttpServer: a static file body cut short closes the connection instead of serving the pipelined request (plaintext and TLS) +HttpServer: a static file body cut short closes the connection instead of serving the pipelined request --EXTENSIONS-- true_async_server true_async --SKIPIF-- true, 'proc_open' => true, 'php_ssl' => true]); +if (!function_exists('proc_open')) die('skip needs proc_open'); ?> --FILE-- addListener('127.0.0.1', $port, $scheme === 'ssl') + ->addListener('127.0.0.1', $port) ->setReadTimeout(10)->setWriteTimeout(10); - if ($scheme === 'ssl') { - $config->enableTls(true)->setCertificate($cert)->setPrivateKey($key); - } - $server = new HttpServer($config); $server->addStaticHandler((new StaticHandler('/s/', $root))->setOpenFileCache(16, 60)); $client = spawn(function () use ($server, $scheme, $port, $file) { usleep(100000); /* Fills the cache with the 2 MiB size. */ - $warm = h1_pipeline_probe($scheme, $port, '/s/small.txt', '/s/big.bin'); - echo "$scheme warm: ", str_contains($warm, 'extra=200') ? 'served' : $warm, "\n"; + echo "$scheme warm: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); $fp = fopen($file, 'r+'); ftruncate($fp, 4096); @@ -87,9 +77,6 @@ foreach (['tcp', 'ssl'] as $scheme) { } ?> --EXPECT-- -tcp warm: served +tcp warm: status=200 declared=2097152 got=2097152 extra=200 end=closed tcp short: status=200 declared=2097152 got=4096 extra=none end=closed tcp after: status=200 declared=4096 got=4096 extra=200 end=closed -ssl warm: served -ssl short: status=200 declared=2097152 got=4096 extra=none end=closed -ssl after: status=200 declared=4096 got=4096 extra=200 end=closed diff --git a/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt b/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt new file mode 100644 index 00000000..d9abd511 --- /dev/null +++ b/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt @@ -0,0 +1,91 @@ +--TEST-- +HttpServer: a static file body cut short over TLS closes the connection instead of serving the pipelined request +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'proc_open' => true, 'php_ssl' => true]); +?> +--FILE-- +addListener('127.0.0.1', $port, $scheme === 'ssl') + ->setReadTimeout(10)->setWriteTimeout(10); + + if ($scheme === 'ssl') { + $config->enableTls(true)->setCertificate($cert)->setPrivateKey($key); + } + + $server = new HttpServer($config); + $server->addStaticHandler((new StaticHandler('/s/', $root))->setOpenFileCache(16, 60)); + + $client = spawn(function () use ($server, $scheme, $port, $file) { + usleep(100000); + /* Fills the cache with the 2 MiB size. */ + echo "$scheme warm: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + + $fp = fopen($file, 'r+'); + ftruncate($fp, 4096); + fclose($fp); + clearstatcache(); + + echo "$scheme short: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + /* The failure evicted the stale entry, so the size is taken afresh. */ + echo "$scheme after: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + $server->stop(); + }); + + spawn(function () use ($server) { + usleep(15000000); + if ($server->isRunning()) { $server->stop(); } + }); + + $server->start(); + await($client); +} +?> +--EXPECT-- +ssl warm: status=200 declared=2097152 got=2097152 extra=200 end=closed +ssl short: status=200 declared=2097152 got=4096 extra=none end=closed +ssl after: status=200 declared=4096 got=4096 extra=200 end=closed From f949d0bbff0636ca0c627fddd9d701762ec19750 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:21:29 +0300 Subject: [PATCH 05/10] test(h1): hold the truncated body short, not to its length, which Windows does not send (#362) --- dev/PLAN.md | 22 +++++++++++++++++-- .../h1/065-h1-static-short-body-closes.phpt | 15 +++++++------ .../068-h1-tls-static-short-body-closes.phpt | 4 ++-- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/dev/PLAN.md b/dev/PLAN.md index aedbc93c..e6a8ef76 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -125,10 +125,24 @@ value, not a count. exception clear or the eviction removed, 067 or 065 fails. Suite 520 of 545 passed, 25 skipped, 0 failed. - [ ] S2.4 php-async: TransmitFile reports the bytes it sent - done: a php-async PR merged with CI green; the server's static + done: php-async PR #308 merged with CI green; the server's static short-transfer test passes on Windows against it tier: T1 · role: — -- [ ] S2.5 Issue, CHANGELOG and the server PR + CI 2026-09-30 (server #363 against php-async main): Windows already + closes after the truncated file, with 0 body bytes, so 065 asserts a + short body, not its length; #308 stays for the count it reports. +- [ ] S2.5 php-async: a sendfile the socket refuses with EAGAIN waits for it + instead of failing + done: on macOS CI the server's 2 MiB plaintext file arrives whole + (`h1/065` warm line), red on php-async main; dispose during the wait + frees the request + tier: T2 · role: Critic + CI 2026-09-30: macOS debug and release cut the 2 MiB warm-up transfer at + 1,129,404 and 998,696 bytes. libuv's Darwin/FreeBSD sendfile returns + -1/EAGAIN when a full socket took nothing (src/unix/fs.c), and + `io_sendfile_zc_cb` completes that as an error. Linux goes through + libuv's poll-based emulation. Edmond 2026-09-30: fix it in this stage. +- [ ] S2.6 Issue, CHANGELOG and the server PR done: merged with every CI platform green tier: T1 · role: — @@ -295,6 +309,10 @@ Added by the health check of 2026-09-30 (`dev/HEALTH.md`), in this order: 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. - [ ] **Drop the streaming exemption in laravel-spawn.** `TrueAsyncServer::streamContent` calls `setNoCompression()` on every `StreamedResponse` as the workaround for #170 diff --git a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt index 3e864184..38b45c6a 100644 --- a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt +++ b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt @@ -11,11 +11,12 @@ if (!function_exists('proc_open')) die('skip needs proc_open'); ---EXPECT-- +--EXPECTF-- tcp warm: status=200 declared=2097152 got=2097152 extra=200 end=closed -tcp short: status=200 declared=2097152 got=4096 extra=none end=closed +tcp short: status=200 declared=2097152 got=%d extra=none end=closed tcp after: status=200 declared=4096 got=4096 extra=200 end=closed diff --git a/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt b/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt index d9abd511..3a6827e9 100644 --- a/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt +++ b/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt @@ -85,7 +85,7 @@ foreach (['ssl'] as $scheme) { await($client); } ?> ---EXPECT-- +--EXPECTF-- ssl warm: status=200 declared=2097152 got=2097152 extra=200 end=closed -ssl short: status=200 declared=2097152 got=4096 extra=none end=closed +ssl short: status=200 declared=2097152 got=%d extra=none end=closed ssl after: status=200 declared=4096 got=4096 extra=200 end=closed From ecc6cb4314a6c70ca64d265b31433a0499577cf1 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:21:29 +0300 Subject: [PATCH 06/10] ci: build against php-async transmitfile-sent-count (temporary, reverted before merge) --- .github/workflows/build-linux.yml | 2 +- .github/workflows/build-macos.yml | 2 +- .github/workflows/build-windows.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index de64939a..b145e6c0 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -33,7 +33,7 @@ env: # --disable-all + a fixed allowlist (matches the modules the H3 tests # expect to be present at runtime). PHP_SRC_BRANCH: true-async - PHP_ASYNC_BRANCH: main + PHP_ASYNC_BRANCH: transmitfile-sent-count jobs: # -------------------------------------------------------------------- diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index b93cc470..79f37b51 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -17,7 +17,7 @@ on: env: PHP_SRC_BRANCH: true-async - PHP_ASYNC_BRANCH: main + PHP_ASYNC_BRANCH: transmitfile-sent-count jobs: build-macos: diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 02292f04..1f41dcd7 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -21,7 +21,7 @@ on: env: PHP_SRC_BRANCH: true-async - PHP_ASYNC_BRANCH: main + PHP_ASYNC_BRANCH: transmitfile-sent-count jobs: windows: From 8d1e64d10d9e7d5be06c80c90fa4eae0830aee02 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:16:08 +0300 Subject: [PATCH 07/10] Revert "ci: build against php-async transmitfile-sent-count (temporary, reverted before merge)" This reverts commit ecc6cb4314a6c70ca64d265b31433a0499577cf1. --- .github/workflows/build-linux.yml | 2 +- .github/workflows/build-macos.yml | 2 +- .github/workflows/build-windows.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index b145e6c0..de64939a 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -33,7 +33,7 @@ env: # --disable-all + a fixed allowlist (matches the modules the H3 tests # expect to be present at runtime). PHP_SRC_BRANCH: true-async - PHP_ASYNC_BRANCH: transmitfile-sent-count + PHP_ASYNC_BRANCH: main jobs: # -------------------------------------------------------------------- diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 79f37b51..b93cc470 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -17,7 +17,7 @@ on: env: PHP_SRC_BRANCH: true-async - PHP_ASYNC_BRANCH: transmitfile-sent-count + PHP_ASYNC_BRANCH: main jobs: build-macos: diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 1f41dcd7..02292f04 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -21,7 +21,7 @@ on: env: PHP_SRC_BRANCH: true-async - PHP_ASYNC_BRANCH: transmitfile-sent-count + PHP_ASYNC_BRANCH: main jobs: windows: From 5296dfe8d4ada433f55b6ce6338c4170a26b5ffe Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:16:22 +0300 Subject: [PATCH 08/10] docs(plan): close S2.4 and S2.5, queue items 36 and 37 (#362) --- CHANGELOG.md | 2 +- dev/PLAN.md | 50 ++++++++++++++++++++------------------------------ 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e768b558..4f1372a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +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. Evidence: `h1/065` to `h1/068`. +- **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. diff --git a/dev/PLAN.md b/dev/PLAN.md index e6a8ef76..2cca7189 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -105,43 +105,29 @@ value, not a count. - [x] S2.2 Red tests for the three failures handoff: `h1/065`, `066`, `067`, red 5 of 5 and 3 of 3 on eaa2d57. - [x] S2.3 The fix in the server - done: S2.2 green; both `on_done` callbacks honour `status`; a short body - marks the framing lost; plaintext reports `bytes_sent < body_length` as - -1; a failed defer returns its own result and the caller drops - keep-alive after llhttp returns; the whole phpt suite passes - tier: T2 · role: Critic - Critic 2026-09-30 on the diff, taken: a real timer refusal raises an - exception the fault point did not, and a pending one stops the - fallback answer — cleared in `engine_defer_schedule`, the point now - throws the same way; a stale cache entry failed every request until its - TTL — evicted on a non-zero status (`http_static_cache_remove`); the - `SEND_FILE_HANDLED` and `on_done` docs were wrong — rewritten. Known: - Windows needs S2.4. Rejected: the synchronous fallback slurping a large - file after a refusal (the existing path for every refusal, not this - change's); the access log showing a short body as a plain 200 (item 35). - handoff: the defer failure keeps keep-alive, unlike the done line: the - timer is taken before on_armed and a refusal returns SEND_FILE_REFUSED, - which both callers answer whole (S2.2). 065, 066, 067 green; with the - exception clear or the eviction removed, 067 or 065 fails. Suite 520 of - 545 passed, 25 skipped, 0 failed. -- [ ] S2.4 php-async: TransmitFile reports the bytes it sent + handoff: 86111c7; a refused defer answers the request whole + (SEND_FILE_REFUSED), not by dropping keep-alive. Suite 520 of 545. +- [x] S2.4 php-async: TransmitFile reports the bytes it sent done: php-async PR #308 merged with CI green; the server's static short-transfer test passes on Windows against it tier: T1 · role: — - CI 2026-09-30 (server #363 against php-async main): Windows already - closes after the truncated file, with 0 body bytes, so 065 asserts a - short body, not its length; #308 stays for the count it reports. -- [ ] S2.5 php-async: a sendfile the socket refuses with EAGAIN waits for it + handoff: the file pointer did not measure what TransmitFile sent (a full + 2 MiB counted short on CI); each pass is clamped to the file's size and + positioned by the worker. php-async 1521d69; `h1/065` green on Windows. +- [x] S2.5 php-async: a sendfile the socket refuses with EAGAIN waits for it instead of failing done: on macOS CI the server's 2 MiB plaintext file arrives whole (`h1/065` warm line), red on php-async main; dispose during the wait frees the request tier: T2 · role: Critic - CI 2026-09-30: macOS debug and release cut the 2 MiB warm-up transfer at - 1,129,404 and 998,696 bytes. libuv's Darwin/FreeBSD sendfile returns - -1/EAGAIN when a full socket took nothing (src/unix/fs.c), and - `io_sendfile_zc_cb` completes that as an error. Linux goes through - libuv's poll-based emulation. Edmond 2026-09-30: fix it in this stage. + Critic 2026-09-30 on the diff, taken: a close of the destination io ends + the wait (the dup kept the socket open and its number could be reused); + the dup is close-on-exec. Moved to item 36: no write deadline on the + file-body phase (Linux blocks in libuv's poll(-1) alike). Rejected: the + head overtaken by the body on macOS (not shown, and possible before). + handoff: in php-async #308 (1521d69). macOS debug and release: warm line + 2097152 of 2097152, red before at 1129404 and 998696. Dispose during + the wait was exercised on Linux only through a forced-EAGAIN hack. - [ ] S2.6 Issue, CHANGELOG and the server PR done: merged with every CI platform green tier: T1 · role: — @@ -203,7 +189,7 @@ 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: +12a. Done: stage S2 (#362). 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 @@ -313,6 +299,10 @@ Added by the health check of 2026-09-30 (`dev/HEALTH.md`), in this order: 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. - [ ] **Drop the streaming exemption in laravel-spawn.** `TrueAsyncServer::streamContent` calls `setNoCompression()` on every `StreamedResponse` as the workaround for #170 From f5f588371c39e675e34d60c54a28df50252c4712 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:26:07 +0300 Subject: [PATCH 09/10] test(core): hold the fault-point name length at its 63-byte bound (#362) Mutants: S2, hand 10 by 28.2: 9 killed, 1 survived (fault_hooks.c name-length bound, now killed by core/083), 0 not run; base eaa2d57 --- .../server/core/083-fault-injection-hook.phpt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/phpt/server/core/083-fault-injection-hook.phpt b/tests/phpt/server/core/083-fault-injection-hook.phpt index b3b1901c..f56ade21 100644 --- a/tests/phpt/server/core/083-fault-injection-hook.phpt +++ b/tests/phpt/server/core/083-fault-injection-hook.phpt @@ -27,10 +27,15 @@ var_dump(_http_fault_fail($p), _http_fault_hits($p), _http_fault_hits('test/neve _http_fault_disable('test/never-enabled'); -try { - _http_fault_enable(''); -} catch (ValueError $e) { - echo $e->getMessage(), "\n"; +_http_fault_enable(str_repeat('n', 63)); +var_dump(_http_fault_fail(str_repeat('n', 63))); + +foreach (['', str_repeat('n', 64)] as $name) { + try { + _http_fault_enable($name); + } catch (ValueError $e) { + echo $e->getMessage(), "\n"; + } } ?> --EXPECT-- @@ -44,4 +49,6 @@ bool(true) bool(false) int(3) int(0) +bool(true) +_http_fault_enable(): Argument #1 ($name) must be 1 to 63 bytes long _http_fault_enable(): Argument #1 ($name) must be 1 to 63 bytes long From a8be376cfb633b780ccde1440acfc482464926a1 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:36:10 +0300 Subject: [PATCH 10/10] refactor(h1): apply the Code Reviewer pass on S2, and close the stage in the plan (#362) --- dev/PLAN.md | 70 +++--------------- src/core/fault_hooks.c | 2 + src/http1/http1_sendfile.c | 3 +- src/http_send_file.c | 8 +-- src/send_file.c | 2 +- src/static/http_static.c | 4 +- .../h1/065-h1-static-short-body-closes.phpt | 59 ++++++++------- .../068-h1-tls-static-short-body-closes.phpt | 72 +++++++++---------- tests/phpt/server/h1/_pipeline_probe.inc | 2 +- 9 files changed, 84 insertions(+), 138 deletions(-) diff --git a/dev/PLAN.md b/dev/PLAN.md index 2cca7189..73f3d83a 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -83,55 +83,6 @@ socket several workers share on Windows, an EMFILE spin, no named pipes). several held connections, which only a Windows AF_UNIX listener (pending pipe instances) produces: item 34. -## S2 — A failed or short HTTP/1 file body keeps the connection alive (item 12a) [in progress] - -Goal: a file body that fails or comes up short ends the connection, so no later -response is read as the rest of that body. -Done when: item 12a's criterion holds on Linux, macOS and Windows CI. -Tests: first -Base: eaa2d57 (server), a97cd13 (php-async) - -Critic 2026-09-30 on the plan, all taken: `should_continue=false` still serves a -pipelined request (`http_request_finalize` drains the buffer unless the framing -is lost); the defer failure finalizes inside `llhttp_execute`, so closing there -frees what the parser still reads; a plaintext truncation is absorbed by the -socket buffers, while a stale `st_size` in the open-file cache gives a -deterministic short transfer; php-async's TransmitFile counts the requested -bytes, so Windows cannot see a short transfer; libfiu's `failnum` is a return -value, not a count. - -- [x] S2.1 Fault injection in the build - handoff: `src/core/fault_hooks.c`; `core/083` passes with the flag, skips without. -- [x] S2.2 Red tests for the three failures - handoff: `h1/065`, `066`, `067`, red 5 of 5 and 3 of 3 on eaa2d57. -- [x] S2.3 The fix in the server - handoff: 86111c7; a refused defer answers the request whole - (SEND_FILE_REFUSED), not by dropping keep-alive. Suite 520 of 545. -- [x] S2.4 php-async: TransmitFile reports the bytes it sent - done: php-async PR #308 merged with CI green; the server's static - short-transfer test passes on Windows against it - tier: T1 · role: — - handoff: the file pointer did not measure what TransmitFile sent (a full - 2 MiB counted short on CI); each pass is clamped to the file's size and - positioned by the worker. php-async 1521d69; `h1/065` green on Windows. -- [x] S2.5 php-async: a sendfile the socket refuses with EAGAIN waits for it - instead of failing - done: on macOS CI the server's 2 MiB plaintext file arrives whole - (`h1/065` warm line), red on php-async main; dispose during the wait - frees the request - tier: T2 · role: Critic - Critic 2026-09-30 on the diff, taken: a close of the destination io ends - the wait (the dup kept the socket open and its number could be reused); - the dup is close-on-exec. Moved to item 36: no write deadline on the - file-body phase (Linux blocks in libuv's poll(-1) alike). Rejected: the - head overtaken by the body on macOS (not shown, and possible before). - handoff: in php-async #308 (1521d69). macOS debug and release: warm line - 2097152 of 2097152, red before at 1129404 and 998696. Dispose during - the wait was exercised on Linux only through a forced-EAGAIN hack. -- [ ] S2.6 Issue, CHANGELOG and the server PR - done: merged with every CI platform green - tier: T1 · role: — - ## Next ### Order of the open defects, 2026-09-28 @@ -189,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. Done: stage S2 (#362). 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 @@ -303,6 +247,10 @@ Added by the health check of 2026-09-30 (`dev/HEALTH.md`), in this order: 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 diff --git a/src/core/fault_hooks.c b/src/core/fault_hooks.c index c3bae799..92185afd 100644 --- a/src/core/fault_hooks.c +++ b/src/core/fault_hooks.c @@ -118,6 +118,8 @@ PHP_FUNCTION(_http_fault_enable) fault_point_t *point = fault_point_find(ZSTR_VAL(name)); + /* The slot is taken without a lock: points are enabled from the test's + * own PHP thread, one at a time; reactor threads only read the table. */ if (point == NULL) { const int used = zend_atomic_int_load(&fault_points_used); diff --git a/src/http1/http1_sendfile.c b/src/http1/http1_sendfile.c index bc004864..56b5a88d 100644 --- a/src/http1/http1_sendfile.c +++ b/src/http1/http1_sendfile.c @@ -419,7 +419,8 @@ static void h1_send_handle_tls_read_done(h1_send_state_t *state, { if (UNEXPECTED(err) || bytes_read < 0) { /* Read error mid-stream — bytes already on the wire belong - * to the client, finalize and let keep-alive policy decide. */ + * to the client; finalize, and on_done ends the connection + * on the non-zero status. */ state->status = -1; h1_send_finalize(state); return; diff --git a/src/http_send_file.c b/src/http_send_file.c index 267d4bf0..1c7c5771 100644 --- a/src/http_send_file.c +++ b/src/http_send_file.c @@ -224,15 +224,15 @@ bool http_send_file_dispatch(http_request_t *request, zend_object *response_obj, return true; } - /* Engine refused before kick-off (MAXPATHLEN, FS_OPEN failure, - * cb alloc, no timer for the deferred start). adapter wasn't handed - * off; on_done not fired. */ + /* Engine refused before kick-off: HANDLED for its arguments (a path + * over MAXPATHLEN, no protocol op), REFUSED for no timer to defer the + * start. adapter wasn't handed off; on_done not fired. */ sf_adapter_free(adapter); if (r != SEND_FILE_HANDLED) { http_response_synth_error(response_obj, 500, "sendFile: engine refused"); } else { - http_response_synth_error(response_obj, 500, "sendFile: cannot open file"); + http_response_synth_error(response_obj, 500, "sendFile: engine refused its arguments"); } return false; diff --git a/src/send_file.c b/src/send_file.c index d8b1d96c..456af448 100644 --- a/src/send_file.c +++ b/src/send_file.c @@ -187,7 +187,7 @@ static void engine_finalize(engine_state_t *state, int status) /* A failed body may have been sized from a cache entry the file no longer * matches; left in place it would fail every request until its TTL. */ - if (status != 0 && state->fs_path != NULL) { + if (status != 0 && state->armed && state->fs_path != NULL) { http_static_cache_t *const cache = engine_cache(&state->cfg); if (cache != NULL) { diff --git a/src/static/http_static.c b/src/static/http_static.c index 563bc21d..5fffedad 100644 --- a/src/static/http_static.c +++ b/src/static/http_static.c @@ -516,8 +516,8 @@ http_static_result_t http_static_try_serve_mounts( return HTTP_STATIC_PASSTHROUGH; } /* SEND_FILE_HANDLED or SEND_FILE_REFUSED — engine refused - * before kick-off (rare: MAXPATHLEN, ZEND_ASYNC_FS_OPEN - * failure, ecalloc failure, no timer for the deferred start). + * before kick-off (rare: a path over MAXPATHLEN, no protocol + * op, no timer for the deferred start). * Fall through to the synchronous fallback path below. */ } diff --git a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt index 38b45c6a..36aada7c 100644 --- a/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt +++ b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt @@ -15,7 +15,7 @@ if (!function_exists('proc_open')) die('skip needs proc_open'); * get, so anything the server writes next on the connection is read as body. * The connection has to close after the short body; the request pipelined * behind it goes unanswered, and the next request for the file is sized from - * the file again. The body is held short, not to its length: on Windows it + * the file again. The short body's length is not pinned: on Windows it * arrives empty. The TLS path is 068. */ require_once __DIR__ . '/../_free_port.inc'; @@ -40,42 +40,41 @@ register_shutdown_function(function () use ($tmp, $root, $file) { require_once __DIR__ . '/_pipeline_probe.inc'; -foreach (['tcp'] as $scheme) { - file_put_contents($file, str_repeat('ABCDEFGHIJKLMNOP', $size / 16)); - file_put_contents("$root/small.txt", 'small'); +$scheme = 'tcp'; +file_put_contents($file, str_repeat('ABCDEFGHIJKLMNOP', $size / 16)); +file_put_contents("$root/small.txt", 'small'); - $port = tas_free_port(); - $config = (new HttpServerConfig()) - ->addListener('127.0.0.1', $port) - ->setReadTimeout(10)->setWriteTimeout(10); +$port = tas_free_port(); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port) + ->setReadTimeout(10)->setWriteTimeout(10); - $server = new HttpServer($config); - $server->addStaticHandler((new StaticHandler('/s/', $root))->setOpenFileCache(16, 60)); +$server = new HttpServer($config); +$server->addStaticHandler((new StaticHandler('/s/', $root))->setOpenFileCache(16, 60)); - $client = spawn(function () use ($server, $scheme, $port, $file) { - usleep(100000); - /* Fills the cache with the 2 MiB size. */ - echo "$scheme warm: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); +$client = spawn(function () use ($server, $scheme, $port, $file) { + usleep(100000); + /* Fills the cache with the 2 MiB size. */ + echo "$scheme warm: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); - $fp = fopen($file, 'r+'); - ftruncate($fp, 4096); - fclose($fp); - clearstatcache(); + $fp = fopen($file, 'r+'); + ftruncate($fp, 4096); + fclose($fp); + clearstatcache(); - echo "$scheme short: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); - /* The failure evicted the stale entry, so the size is taken afresh. */ - echo "$scheme after: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); - $server->stop(); - }); + echo "$scheme short: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + /* The failure evicted the stale entry, so the size is taken afresh. */ + echo "$scheme after: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + $server->stop(); +}); - spawn(function () use ($server) { - usleep(15000000); - if ($server->isRunning()) { $server->stop(); } - }); +spawn(function () use ($server) { + usleep(15000000); + if ($server->isRunning()) { $server->stop(); } +}); - $server->start(); - await($client); -} +$server->start(); +await($client); ?> --EXPECTF-- tcp warm: status=200 declared=2097152 got=2097152 extra=200 end=closed diff --git a/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt b/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt index 3a6827e9..9d1e8afd 100644 --- a/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt +++ b/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt @@ -12,11 +12,11 @@ tls_skipif(['openssl_cli' => true, 'proc_open' => true, 'php_ssl' => true]); addListener('127.0.0.1', $port, $scheme === 'ssl') - ->setReadTimeout(10)->setWriteTimeout(10); +$port = tas_free_port(); +$config = (new HttpServerConfig()) + ->addListener('127.0.0.1', $port, true) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setReadTimeout(10)->setWriteTimeout(10); - if ($scheme === 'ssl') { - $config->enableTls(true)->setCertificate($cert)->setPrivateKey($key); - } +$server = new HttpServer($config); +$server->addStaticHandler((new StaticHandler('/s/', $root))->setOpenFileCache(16, 60)); - $server = new HttpServer($config); - $server->addStaticHandler((new StaticHandler('/s/', $root))->setOpenFileCache(16, 60)); +$client = spawn(function () use ($server, $scheme, $port, $file) { + usleep(100000); + /* Fills the cache with the 2 MiB size. */ + echo "$scheme warm: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); - $client = spawn(function () use ($server, $scheme, $port, $file) { - usleep(100000); - /* Fills the cache with the 2 MiB size. */ - echo "$scheme warm: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + $fp = fopen($file, 'r+'); + ftruncate($fp, 4096); + fclose($fp); + clearstatcache(); - $fp = fopen($file, 'r+'); - ftruncate($fp, 4096); - fclose($fp); - clearstatcache(); - - echo "$scheme short: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); - /* The failure evicted the stale entry, so the size is taken afresh. */ - echo "$scheme after: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); - $server->stop(); - }); + echo "$scheme short: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + /* The failure evicted the stale entry, so the size is taken afresh. */ + echo "$scheme after: ", h1_pipeline_probe($scheme, $port, '/s/big.bin', '/s/small.txt'); + $server->stop(); +}); - spawn(function () use ($server) { - usleep(15000000); - if ($server->isRunning()) { $server->stop(); } - }); +spawn(function () use ($server) { + usleep(15000000); + if ($server->isRunning()) { $server->stop(); } +}); - $server->start(); - await($client); -} +$server->start(); +await($client); ?> --EXPECTF-- ssl warm: status=200 declared=2097152 got=2097152 extra=200 end=closed diff --git a/tests/phpt/server/h1/_pipeline_probe.inc b/tests/phpt/server/h1/_pipeline_probe.inc index 5166bb9b..76a32447 100644 --- a/tests/phpt/server/h1/_pipeline_probe.inc +++ b/tests/phpt/server/h1/_pipeline_probe.inc @@ -63,7 +63,7 @@ $end = feof($c) ? 'closed' : 'open'; $head_end = strpos($in, "\r\n\r\n"); if ($head_end === false) { - echo "status=none got=0 extra=none end=$end\n"; + echo "status=none declared=? got=0 extra=none end=$end\n"; exit(0); }