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/CHANGELOG.md b/CHANGELOG.md index 64dd2fa2..4f1372a6 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. 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/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/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 6711e86b..73f3d83a 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -140,22 +140,15 @@ an item marked "reproduce" gets its failing run before any code. 12. `http_server_pause_listeners` and the accept it may not stop: stage S1 above (#359, true-async/php-async#305). The accept errors it used to drop are counted in `accept_errors_total`; they are still not logged. -12a. A failed or short HTTP/1 file body keeps the connection alive: - `h1_static_on_static_done` and `h1_sendfile_on_done` discard `status` - (`http_connection.c:2744`, `:2833`), the plaintext completion reports a - short transfer as success (`http1_sendfile.c:297-309`), and a failed defer - schedule finalizes with nothing written (`send_file.c:617`, `:758`). The - next response on the connection follows a short body, against USAGE.md:195. - Done when a file truncated mid-send, plaintext and TLS, drops keep-alive and - a pipelined second request gets no bytes glued to the first body (template: - `h3/056`). Reproduce. Found by the health check, verified by the Sage. +12a. A failed or short HTTP/1 file body kept the connection alive: done, + stage S2 (#362, true-async/php-async#308); tests `h1/065` to `h1/068`. 12b. The same on HTTP/2: a truncated or failed file body ends with END_STREAM (`http2_static_response.c:593-603`). Done when it sends RST_STREAM(INTERNAL_ERROR) and an h2 test that truncates a file reads error code 2. Reproduce. - The failure in 12a and 12b comes from a libfiu fault point (`fiu_do_on`) - in the sendfile engine; installing libfiu and wiring it into the debug - build is part of 12a (`dev/DECISIONS.md`, 2026-09-30). + The failure comes from a libfiu fault point, as `h1/066` does; the build + flag and the `_http_fault_*` hooks exist (`src/core/fault_hooks.c`), and + `h1/065`'s stale-cache truncation is the template for a real short file. 13. `core/018` under load: its DEBUG run overflows the log ring and the sink's `ring overflow, dropped=N` line on stderr fails the test. 30 of 30 runs with six copies in parallel, on `main` and on the #313 branch alike; 8 of 8 @@ -243,6 +236,21 @@ Added by the health check of 2026-09-30 (`dev/HEALTH.md`), in this order: hold up to four pending instances): all are served after the resume, in order. The only case where php-async's delivery loop runs more than once, so its two outcome branches have no test that kills a mutant of them. +35. A file body that failed or came up short is access-logged and counted as a + plain 200 with a short byte count (S2.3): mark it failed, as an aborted + stream is. +36. The HTTP/1 file-body phase has no write deadline: a peer that stops reading + holds the chain, and with it the connection, for good (Linux: libuv's copy + loop in `poll(-1)`; macOS: php-async's EAGAIN wait). Critic on php-async + #308, 2026-09-30. Reproduce with a client that reads 100 KiB and stalls. +37. `multipart/009` failed once on Windows (PR 363, run 36743616544, 1 of 4 + runs of that branch): the third upload's `getSize()` read NULL, an upload + error. Passed on rerun and on main 8 of 8. Suspect the temporary file + (item 18's CANT_WRITE path); reproduce under `-j` on Windows. +38. A small php-async PR: regenerate `async_arginfo.h` (its stub hash is stale, + every build rewrites it), and assert in `sendfile_wait_writable` that the + destination io has no other sendfile waiting, since `sendfile_waiting` + holds one (Code Reviewer on S2, 2026-09-30). - [ ] **Drop the streaming exemption in laravel-spawn.** `TrueAsyncServer::streamContent` calls `setNoCompression()` on every `StreamedResponse` as the workaround for #170 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/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/fault_hooks.c b/src/core/fault_hooks.c new file mode 100644 index 00000000..92185afd --- /dev/null +++ b/src/core/fault_hooks.c @@ -0,0 +1,220 @@ +/* + +----------------------------------------------------------------------+ + | 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)); + + /* 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); + + 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/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 88fecca8..56b5a88d 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); @@ -348,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); } @@ -410,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 3372e362..1c7c5771 100644 --- a/src/http_send_file.c +++ b/src/http_send_file.c @@ -224,14 +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). 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/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/src/send_file.c b/src/send_file.c index d931bf24..456af448 100644 --- a/src/send_file.c +++ b/src/send_file.c @@ -32,6 +32,8 @@ #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 "zend_exceptions.h" #include #ifdef PHP_WIN32 @@ -156,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); @@ -172,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->armed && 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); @@ -316,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, @@ -561,10 +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) { 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; } @@ -597,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 @@ -742,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..5fffedad 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: a path over MAXPATHLEN, no protocol + * op, 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/core/083-fault-injection-hook.phpt b/tests/phpt/server/core/083-fault-injection-hook.phpt new file mode 100644 index 00000000..f56ade21 --- /dev/null +++ b/tests/phpt/server/core/083-fault-injection-hook.phpt @@ -0,0 +1,54 @@ +--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) +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 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..36aada7c --- /dev/null +++ b/tests/phpt/server/h1/065-h1-static-short-body-closes.phpt @@ -0,0 +1,82 @@ +--TEST-- +HttpServer: a static file body cut short closes the connection instead of serving the pipelined request +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + +--FILE-- +addListener('127.0.0.1', $port) + ->setReadTimeout(10)->setWriteTimeout(10); + +$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); +?> +--EXPECTF-- +tcp warm: status=200 declared=2097152 got=2097152 extra=200 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/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/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..9d1e8afd --- /dev/null +++ b/tests/phpt/server/h1/068-h1-tls-static-short-body-closes.phpt @@ -0,0 +1,87 @@ +--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, true) + ->enableTls(true)->setCertificate($cert)->setPrivateKey($key) + ->setReadTimeout(10)->setWriteTimeout(10); + +$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); +?> +--EXPECTF-- +ssl warm: status=200 declared=2097152 got=2097152 extra=200 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 diff --git a/tests/phpt/server/h1/_pipeline_probe.inc b/tests/phpt/server/h1/_pipeline_probe.inc new file mode 100644 index 00000000..76a32447 --- /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 declared=? 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);