Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>:<port>: 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
Expand Down
29 changes: 26 additions & 3 deletions src/http_server_class.c
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/send_file.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion src/static/static_handler_class.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions tests/phpt/server/static/009-static-symlink-owner.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ StaticHandler: SymlinkPolicy::OwnerMatch follows owner-equal links (issue #13 §
--EXTENSIONS--
true_async_server
true_async
--SKIPIF--
<?php
/* Windows: skipped as a KNOWN LIMITATION, not a flaky/outdated test.
* SymlinkPolicy::OwnerMatch walks the path and compares lstat()/stat() uids
* — POSIX ownership semantics that do not map to Windows. symlink() also
* requires privilege on Windows, so the fixture can't be built. Tracked
* alongside the REJECT/O_NOFOLLOW Windows gap (see 021). */
if (substr(PHP_OS, 0, 3) === 'WIN') die('skip OwnerMatch is POSIX uid-based; symlink policy not validated on Windows (tracked gap)');
?>
--FILE--
<?php
/* OwnerMatch was aliased to Reject in the MVP. The real implementation
Expand Down
11 changes: 11 additions & 0 deletions tests/phpt/server/static/021-static-symlink-reject-nofollow.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ StaticHandler: SymlinkPolicy::REJECT — engine open(2) uses O_NOFOLLOW so a sym
--EXTENSIONS--
true_async_server
true_async
--SKIPIF--
<?php
/* Windows: skipped as a KNOWN LIMITATION, not a flaky/outdated test.
* SymlinkPolicy::REJECT is enforced atomically by open(O_NOFOLLOW) in
* send_file.c / http_static_safety.c. O_NOFOLLOW does not exist on Windows,
* so those `#ifdef O_NOFOLLOW` backstops compile out and the TOCTOU
* symlink-escape guard this test exercises cannot be validated until a
* Windows reparse-point reject (FILE_FLAG_OPEN_REPARSE_POINT) is added.
* symlink() also requires privilege on Windows, so the fixture can't build. */
if (substr(PHP_OS, 0, 3) === 'WIN') die('skip REJECT relies on open(O_NOFOLLOW), POSIX-only; Windows enforcement is a tracked gap');
?>
--FILE--
<?php
/* The open-file cache skips the per-request lstat pre-flight on a hit
Expand Down
Loading