diff --git a/CHANGELOG.md b/CHANGELOG.md index b631d78..f8d001c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Windows: TCP listeners now bind.** The server failed to start on Windows + with `Async\AsyncException: Failed to bind to :: operation not + supported on socket`. The listener requested `SO_REUSEPORT`, which libuv's + `uv_tcp_bind()` rejects with `UV_ENOTSUP` on Windows (Winsock has no + `SO_REUSEPORT`). REUSEPORT is now treated as a platform capability and never + requested on Windows; the default single-listener server binds directly. No + change on Linux/BSD/macOS (#82). +- **Windows: `StaticHandler` accepts native absolute paths.** Root-directory + validation only accepted a leading `/`, rejecting every Windows path + (`C:\...`) and making `StaticHandler` unusable there. It now uses + `IS_ABSOLUTE_PATH` (drive-letter / UNC on Windows, leading `/` on POSIX). +- **Windows: static file bodies are served binary-clean.** The `send_file` + engine opened files without `O_BINARY`, so Windows text-mode translation + could corrupt or truncate binary bodies (precompressed `.br`/`.gz`, byte + ranges, images). It now opens with `O_BINARY`, matching the policy open path. + ## [0.7.2] - 2026-06-02 ### Added diff --git a/src/http_server_class.c b/src/http_server_class.c index 87b09b2..157c206 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -100,6 +100,26 @@ static bool http_server_use_shared_listen_fd(void) #endif } +/* Whether to ask the reactor for SO_REUSEPORT on each listener. This is a + * kernel capability, NOT the logical inverse of the shared-fd strategy — + * conflating the two is what broke Windows (issue #82). Three platform camps: + * - Linux/FreeBSD: load-balanced REUSEPORT, each worker binds itself. + * - macOS/other BSD: no LB REUSEPORT, so the shared-fd dup model instead. + * - Windows: NEITHER. Winsock has no SO_REUSEPORT; libuv's uv_tcp_bind() + * returns UV_ENOTSUP ("operation not supported on socket") if + * UV_TCP_REUSEPORT is set, so it must never be requested. A single + * listener (workers=1, the default) then just binds directly. + * On POSIX the answer is still !use_shared_listen_fd(); Windows is the third + * case a lone boolean cannot express, hence its own carve-out here. */ +static bool http_server_use_reuseport(void) +{ +#ifdef PHP_WIN32 + return false; +#else + return !http_server_use_shared_listen_fd(); +#endif +} + /* Max listeners */ #define MAX_LISTENERS 16 @@ -2548,11 +2568,14 @@ ZEND_METHOD(TrueAsync_HttpServer, start) unsigned int listen_flags = 0; zend_async_listen_event_t *listen_event = NULL; - /* Two strategies for a worker pool sharing host:port. REUSEPORT: + /* Strategies for a worker pool sharing host:port. REUSEPORT: * each worker binds independently, the kernel load-balances * (Linux/FreeBSD). Shared fd: the parent bound once and each - * worker adopts a dup (macOS/Windows, no LB REUSEPORT). */ - if (!http_server_use_shared_listen_fd()) { + * worker adopts a dup (macOS/other BSD, no LB REUSEPORT). + * Windows has neither — uv_tcp_bind() rejects UV_TCP_REUSEPORT + * with ENOTSUP and there is no POSIX dup to share — so it takes + * the plain-bind fall-through below (single listener only). */ + if (http_server_use_reuseport()) { listen_flags |= ZEND_ASYNC_LISTEN_F_REUSEPORT; } #ifndef PHP_WIN32 diff --git a/src/send_file.c b/src/send_file.c index dac6728..4d371f3 100644 --- a/src/send_file.c +++ b/src/send_file.c @@ -714,6 +714,12 @@ send_file_result_t send_file(struct http_request_t *request, zend_object *respon /* === Synchronous open(2) ========================================= */ int open_flags = O_RDONLY | O_CLOEXEC; +#ifdef O_BINARY + /* Windows opens in text mode by default: CRLF translation and a 0x1A + * byte treated as EOF would corrupt/truncate binary bodies (precompressed + * .br/.gz, ranges, images). Mirrors open_for_policy in http_static_safety.c. */ + open_flags |= O_BINARY; +#endif #ifdef O_NOFOLLOW /* REJECT mount: reject a final-component symlink atomically (ELOOP). */ if (state->cfg.reject_symlinks) { diff --git a/src/static/static_handler_class.c b/src/static/static_handler_class.c index 93b9824..7cd810a 100644 --- a/src/static/static_handler_class.c +++ b/src/static/static_handler_class.c @@ -117,7 +117,10 @@ static zend_string *canonicalise_root_directory(const zend_string *path) return NULL; } - if (ZSTR_VAL(path)[0] != '/') { + /* Cross-platform absolute-path check: leading '/' on POSIX, drive-letter + * (C:\) or UNC (\\) on Windows. The old `[0] != '/'` test rejected every + * valid Windows path, making StaticHandler unusable there. */ + if (!IS_ABSOLUTE_PATH(ZSTR_VAL(path), ZSTR_LEN(path))) { zend_throw_exception(http_server_invalid_argument_exception_ce, "StaticHandler root directory must be an absolute path", 0); return NULL; diff --git a/tests/phpt/server/static/009-static-symlink-owner.phpt b/tests/phpt/server/static/009-static-symlink-owner.phpt index 187b679..d27cbca 100644 --- a/tests/phpt/server/static/009-static-symlink-owner.phpt +++ b/tests/phpt/server/static/009-static-symlink-owner.phpt @@ -3,6 +3,15 @@ StaticHandler: SymlinkPolicy::OwnerMatch follows owner-equal links (issue #13 § --EXTENSIONS-- true_async_server true_async +--SKIPIF-- + --FILE-- --FILE--