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 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); }