From 2090fd6e32bb15a0cb649725ab88655d6faa0cf0 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:01:32 +0300 Subject: [PATCH 1/2] fix(http): lingering close so error responses survive close-during-upload (Windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the server rejects a request mid-upload (e.g. 413 on an over-limit chunked body), it sent the 4xx then immediately closed the socket. On Windows, closesocket() with unread recv data (the client's in-flight upload) forces an abortive RST that discards the just-sent response, so the client received nothing (empty status). Linux delivered it by luck of softer RST-on-unread timing. Fix: after emit_parse_error sends the 4xx, shutdown(SD_SEND) flushes it + FIN, then the connection enters a lingering-close state — keep reading and DISCARDING the peer's remaining upload until its FIN (EOF -> destroy) or a 5s budget (bounded by the periodic deadline_tick). The final close is then a clean FIN, not an RST. Cross-platform (SHUT_WR on POSIX). Fixes h1/005 on Windows. Full server suite: no regressions (135 pass / 8 fail, all pre-existing; was 133/10). --- src/core/http_connection.c | 51 +++++++++++++++++++++++++++++++++++--- src/core/http_connection.h | 7 ++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/core/http_connection.c b/src/core/http_connection.c index 9349cd6..63d7b50 100644 --- a/src/core/http_connection.c +++ b/src/core/http_connection.c @@ -57,6 +57,19 @@ extern void http_response_set_default_json_flags(zend_object *, uint32_t); # define MSG_NOSIGNAL 0 #endif +/* Half-close the send direction: SD_SEND on Winsock, SHUT_WR on POSIX. */ +#ifdef _WIN32 +# define HTTP_SHUT_WR SD_SEND +#else +# define HTTP_SHUT_WR SHUT_WR +#endif + +/* Lingering-close budget (ms). After an error response is sent mid-upload + * we keep draining the peer's body for at most this long (refreshed on + * activity) before a forced close, so a peer that never sends FIN can't + * pin the connection open. */ +#define HTTP_LINGER_CLOSE_MS 5000 + #define DEFAULT_READ_BUFFER_SIZE 8192 extern zval* http_request_create_from_parsed(http_request_t *req); @@ -895,6 +908,16 @@ static bool http_connection_handle_read_completion(http_connection_t *conn, { *should_destroy_out = false; + /* Lingering close: a 4xx was already sent + FIN'd; we are only draining + * the peer's leftover upload now. Discard every chunk (don't feed the + * parser), refresh the linger deadline, and stay armed. Peer FIN (EOF) + * is handled in the read callback (→ destroy). */ + if (UNEXPECTED(conn->lingering)) { + conn->read_buffer_len = 0; + conn->deadline_ms = ZEND_ASYNC_NOW() + HTTP_LINGER_CLOSE_MS; + return true; + } + if (UNEXPECTED(!conn->protocol_detected) && !detect_and_assign_protocol(conn)) { return true; /* Need more data for detection — caller re-arms */ } @@ -968,8 +991,18 @@ static bool http_connection_handle_read_completion(http_connection_t *conn, return false; } - if (conn->parser != NULL) { - (void)http_connection_emit_parse_error(conn, conn->parser); + if (conn->parser != NULL && http_connection_emit_parse_error(conn, conn->parser) + && conn->io != NULL) { + /* 4xx sent + FIN'd (shutdown in emit). Enter lingering close: + * keep reading and discarding the peer's remaining upload so the + * final close is a clean FIN, not an RST that would wipe the + * response on Windows. Peer FIN (EOF) → destroy in the read cb; + * the deadline_tick force-closes a peer that never sends FIN. */ + conn->lingering = 1; + conn->read_buffer_len = 0; + conn->deadline_ms = ZEND_ASYNC_NOW() + HTTP_LINGER_CLOSE_MS; + *should_destroy_out = false; + return false; } *should_destroy_out = true; return false; @@ -1085,7 +1118,7 @@ static void http_connection_read_callback_fn( * tear the conn down once it (and any pipelined chain) has finished * responding. Without this, an EOF from a peer that sent its last * request and shut down the write half kills mid-flight responses. */ - if (conn->request_in_flight || conn->read_buffer_len > 0) { + if (!conn->lingering && (conn->request_in_flight || conn->read_buffer_len > 0)) { conn->keep_alive = false; return; } @@ -1102,7 +1135,7 @@ static void http_connection_read_callback_fn( * could on_message_complete and dispatch a *second* handler on the same * conn while the first one's response slot is still live. Just buffer the * tail; handler dispose will pull it out via handle_read_completion. */ - if (!terminal && conn->request_in_flight) { + if (!terminal && conn->request_in_flight && !conn->lingering) { return; } @@ -1913,6 +1946,16 @@ bool http_connection_emit_parse_error(http_connection_t *conn, http1_parser_t *p } const ssize_t sent = send(fd, response, (size_t)n, MSG_NOSIGNAL); + + /* Half-close our send side: flushes the response + FIN while keeping + * the recv side open so handle_read_completion can drain the peer's + * in-flight upload (lingering close) before the final closesocket. + * Without the drain, closing with unread recv data forces an RST that + * discards the just-sent response on Windows. Best-effort. */ + if (sent == (ssize_t)n) { + (void)shutdown(fd, HTTP_SHUT_WR); + } + return sent == (ssize_t)n; } /* }}} */ diff --git a/src/core/http_connection.h b/src/core/http_connection.h index 7d278e5..f44dca1 100644 --- a/src/core/http_connection.h +++ b/src/core/http_connection.h @@ -263,6 +263,13 @@ struct _http_connection_t { * iterating. The destroy defers on this flag instead — see the * gate in http_connection_destroy and the drain in http1_feed. */ unsigned in_parser_feed : 1; + /* Lingering close (graceful). Set after an error response (e.g. 413) + * is sent while the peer is still uploading: we keep reading and + * DISCARDING the peer's remaining body so the eventual close emits a + * clean FIN instead of an RST. Closing a socket with unread recv data + * forces an abortive RST on Windows, which wipes the just-sent + * response. Bounded by deadline_ms / the periodic deadline_tick. */ + unsigned lingering : 1; /* Intrusive doubly-linked node. Dual role: * - while the slot is ALIVE, (next_conn, prev_conn) link into From 277fc52b8590d88dca37474d14e68cff5da24832 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Sat, 6 Jun 2026 11:08:05 +0300 Subject: [PATCH 2/2] fix(static): serve byte-range bodies via read+writev on Windows (not sendfile) A byte-range response delivered its body through ZEND_ASYNC_IO_SENDFILE -> uv_fs_sendfile(out = socket). On Windows a TCP socket is a Winsock SOCKET, not a CRT fd, and libuv's win uv_fs_sendfile writes to the out fd via the CRT _write() -- so the body never reaches the socket (206 headers arrive, body empty). Non-range files within the 64 KiB slurp threshold already dodged this via the slurp+writev fast path; ranges were gated out of it. Fix: let the slurp fast path handle ranges too -- read the file (<= 64 KiB) and cut the [first, first+len) slice from the in-memory buffer, sent via the normal writev path. Cross-platform. Fixes static/011-range on Windows. (A range/file larger than the slurp threshold still uses uv_fs_sendfile and remains broken on Windows -- tracked as a follow-up.) Full suite: no regressions (136 pass / 7 fail, all pre-existing). --- src/send_file.c | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/send_file.c b/src/send_file.c index 4d371f3..b1956a3 100644 --- a/src/send_file.c +++ b/src/send_file.c @@ -479,18 +479,27 @@ static void engine_handle_stat(engine_state_t *state) } /* === Small-file fast path (slurp + inline body) ================== */ - /* uv_fs_sendfile on Linux falls through copy_file_range (EINVAL on - * socket) into a userspace pread+write loop inside a worker thread - * — no kernel zero-copy + a futex round-trip per request. For small - * files the round-trip dominates. Slurp inline and let the protocol - * op writev(headers+body) through the same per-socket queue that - * headers normally use; ordering is then libuv's problem. */ - if (!state->is_range && (size_t)state->st.st_size <= SEND_FILE_SLURP_THRESHOLD && - file_io != NULL) { + /* uv_fs_sendfile slurps to user space then writes; on Linux it falls + * through copy_file_range (EINVAL on socket) into a pread+write loop in a + * worker thread (no zero-copy, a futex round-trip per request). On + * Windows it CANNOT target a socket at all — a Winsock SOCKET is not a + * CRT fd, so the sendfile path below delivers an EMPTY body there. For + * files within the slurp threshold — including byte-range requests, whose + * slice we cut from the in-memory buffer — read the bytes and let the + * protocol op writev(headers+body) through the normal per-socket queue + * instead. (A range/file larger than the threshold still uses sendfile + * and stays broken on Windows — separate follow-up.) */ + if ((size_t)state->st.st_size <= SEND_FILE_SLURP_THRESHOLD && file_io != NULL) { zend_string *body = fs_slurp_fd((int)file_io->descriptor.fd, (size_t)state->st.st_size); if (body != NULL) { - http_response_static_set_body_str(response_obj, body); + if (state->is_range) { + /* Slice [range_first, range_first + body_len) from the buffer. */ + http_response_static_set_body_cstr(response_obj, + ZSTR_VAL(body) + state->range_first, (size_t)body_len); + } else { + http_response_static_set_body_str(response_obj, body); + } zend_string_release(body); if (cfg->counters != NULL) { http_server_count_request(cfg->counters); }