diff --git a/.gitignore b/.gitignore index 0fd189f..a7b375d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,25 @@ -/vendor/ -/composer.lock -/.phpunit.cache/ -/.phpunit.result.cache -/phpunit.xml -/.idea/ -/.vscode/ -/.DS_Store -/build/ +# IDE +.idea/ +.vscode/ +.duyler/ + +# Package +vendor/ +coverage/ +.phpunit.cache/ +composer.lock /.php-cs-fixer.cache -/.phpstan.cache +/coverage.xml +.phpactor.json + +# Duyler /docs/ -/examples/ + +# For LLM +.claude/ +.opencode/ +.cursor/ +.sisyphus/ + +# MacOS +/.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c0dcc3c --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +.PHONY: tests +tests: + docker-compose run --rm php vendor/bin/phpunit + +.PHONY: infection +infection: + docker-compose run --rm php vendor/bin/infection + +.PHONY: psalm +psalm: + docker-compose run --rm php vendor/bin/psalm + +.PHONY: cs-fix +cs-fix: + docker-compose run --rm php vendor/bin/php-cs-fixer fix + +.PHONY: rector +rector: + docker-compose run --rm php vendor/bin/rector + +.PHONY: shell +shell: + docker-compose run --rm php /bin/bash + +.PHONY: coverage +coverage: + docker-compose run --rm php vendor/bin/phpunit --coverage-html=coverage --coverage-text + +.PHONY: update +update: + docker-compose run --rm php composer update + +.PHONY: init +init: + @if [ -z "$(NAME)" ]; then \ + echo "Error: Please specify a package name."; \ + echo "Example: make init NAME=my-awesome-package"; \ + exit 1; \ + fi + + @echo "Starting to initialize the package: $(NAME)..." + + $(eval PASCAL_NAME=$(shell echo $(NAME) | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++){$$i=toupper(substr($$i,1,1)) substr($$i,2)}}1' OFS='')) + + @sed -i.bak 's/package-template/$(NAME)/g' composer.json && rm composer.json.bak + @sed -i.bak 's/PackageTemplate/$(PASCAL_NAME)/g' composer.json && rm composer.json.bak + + @sed -i.bak 's/package-template/$(NAME)/g' README.md && rm README.md.bak + @sed -i.bak 's/PackageTemplate/$(PASCAL_NAME)/g' README.md && rm README.md.bak + + @sed -i.bak 's/package-template/$(NAME)/g' sonar-project.properties && rm sonar-project.properties.bak + @sed -i.bak 's/PackageTemplate/$(PASCAL_NAME)/g' sonar-project.properties && rm sonar-project.properties.bak + + @echo "Package name (kebab-case): $(NAME)" + @echo "Namespace (PascalCase): $(PASCAL_NAME)" + + docker-compose build + docker-compose run --rm php composer install + diff --git a/README.md b/README.md index c2d0654..b291149 100644 --- a/README.md +++ b/README.md @@ -205,10 +205,12 @@ use Duyler\HttpServer\WebSocket\WebSocketConfig; use Duyler\HttpServer\WebSocket\Connection; use Duyler\HttpServer\WebSocket\Message; +// Secure by default - origin validation is enabled $wsConfig = new WebSocketConfig( maxMessageSize: 1048576, pingInterval: 30, - validateOrigin: false, + // validateOrigin defaults to true for security + // allowedOrigins defaults to ['*'] - customize for your domains ); $ws = new WebSocketServer($wsConfig); @@ -247,6 +249,19 @@ while (true) { } ``` +#### Public WebSocket Endpoints + +For public WebSocket endpoints that accept connections from any origin: + +```php +// WARNING: This configuration is insecure and exposes your WebSocket +// to CSRF attacks. Only use for truly public endpoints. +$wsConfig = new WebSocketConfig( + validateOrigin: false, // Explicit opt-out of origin validation + allowedOrigins: ['*'], // Explicit wildcard +); +``` + See `examples/websocket-chat.php` for a complete chat application example. ### Static File Serving diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..dc15024 --- /dev/null +++ b/compose.yml @@ -0,0 +1,6 @@ +services: + php: + image: duyler/php-zts:8.5 + volumes: + - .:/app + working_dir: /app diff --git a/coverage.txt b/coverage.txt new file mode 100644 index 0000000..8c9dac4 --- /dev/null +++ b/coverage.txt @@ -0,0 +1,92 @@ + + +Code Coverage Report: + 2026-02-28 08:27:07 + + Summary: + Classes: 21.74% (10/46) + Methods: 49.13% (198/403) + Lines: 52.20% (1681/3220) + +Duyler\HttpServer\Config\ServerConfig + Methods: 50.00% ( 1/ 2) Lines: 40.00% ( 16/ 40) +Duyler\HttpServer\Connection\Connection + Methods: 74.07% (20/27) Lines: 84.44% ( 38/ 45) +Duyler\HttpServer\Connection\ConnectionPool + Methods: 76.92% (10/13) Lines: 93.85% ( 61/ 65) +Duyler\HttpServer\ErrorHandler + Methods: 25.00% ( 2/ 8) Lines: 19.73% ( 29/147) +Duyler\HttpServer\Exception\HttpServerException + Methods: 100.00% ( 3/ 3) Lines: 100.00% ( 3/ 3) +Duyler\HttpServer\Handler\FileDownloadHandler + Methods: 0.00% ( 0/ 4) Lines: 67.44% ( 58/ 86) +Duyler\HttpServer\Handler\StaticFileHandler + Methods: 46.15% ( 6/13) Lines: 92.76% (141/152) +Duyler\HttpServer\Metrics\ServerMetrics + Methods: 100.00% (14/14) Lines: 100.00% ( 53/ 53) +Duyler\HttpServer\Parser\HttpParser + Methods: 37.50% ( 3/ 8) Lines: 75.28% ( 67/ 89) +Duyler\HttpServer\Parser\RequestParser + Methods: 85.71% ( 6/ 7) Lines: 97.09% (100/103) +Duyler\HttpServer\Parser\ResponseWriter + Methods: 50.00% ( 3/ 6) Lines: 73.44% ( 47/ 64) +Duyler\HttpServer\RateLimit\RateLimiter + Methods: 100.00% ( 8/ 8) Lines: 100.00% ( 46/ 46) +Duyler\HttpServer\Server + Methods: 25.00% ( 9/36) Lines: 44.56% (262/588) +Duyler\HttpServer\Socket\SocketErrorSuppressor + Methods: 0.00% ( 0/ 1) Lines: 80.00% ( 4/ 5) +Duyler\HttpServer\Socket\SslSocket + Methods: 30.00% ( 3/10) Lines: 12.50% ( 8/ 64) +Duyler\HttpServer\Socket\StreamSocket + Methods: 40.00% ( 4/10) Lines: 61.43% ( 43/ 70) +Duyler\HttpServer\Socket\StreamSocketResource + Methods: 71.43% ( 5/ 7) Lines: 84.48% ( 49/ 58) +Duyler\HttpServer\Upload\TempFileManager + Methods: 75.00% ( 3/ 4) Lines: 90.91% ( 10/ 11) +Duyler\HttpServer\WebSocket\Enum\Opcode + Methods: 100.00% ( 2/ 2) Lines: 100.00% ( 2/ 2) +Duyler\HttpServer\WebSocket\Frame + Methods: 83.33% ( 5/ 6) Lines: 79.76% ( 67/ 84) +Duyler\HttpServer\WebSocket\Handshake + Methods: 83.33% ( 5/ 6) Lines: 96.00% ( 48/ 50) +Duyler\HttpServer\WebSocket\Message + Methods: 100.00% ( 7/ 7) Lines: 100.00% ( 12/ 12) +Duyler\HttpServer\WebSocket\WebSocketConfig + Methods: 100.00% ( 2/ 2) Lines: 100.00% ( 37/ 37) +Duyler\HttpServer\WebSocket\WebSocketServer + Methods: 50.00% (10/20) Lines: 39.51% ( 32/ 81) +Duyler\HttpServer\WorkerPool\Balancer\LeastConnectionsBalancer + Methods: 100.00% ( 5/ 5) Lines: 100.00% ( 16/ 16) +Duyler\HttpServer\WorkerPool\Balancer\RoundRobinBalancer + Methods: 100.00% ( 5/ 5) Lines: 100.00% ( 13/ 13) +Duyler\HttpServer\WorkerPool\Config\WorkerPoolConfig + Methods: 0.00% ( 0/ 3) Lines: 35.71% ( 10/ 28) +Duyler\HttpServer\WorkerPool\IPC\FdPasser + Methods: 25.00% ( 1/ 4) Lines: 20.88% ( 19/ 91) +Duyler\HttpServer\WorkerPool\IPC\Message + Methods: 87.50% ( 7/ 8) Lines: 97.44% ( 38/ 39) +Duyler\HttpServer\WorkerPool\IPC\UnixSocketChannel + Methods: 77.78% ( 7/ 9) Lines: 63.49% ( 40/ 63) +Duyler\HttpServer\WorkerPool\Master\AbstractMaster + Methods: 50.00% ( 4/ 8) Lines: 37.50% ( 12/ 32) +Duyler\HttpServer\WorkerPool\Master\CentralizedMaster + Methods: 18.18% ( 2/11) Lines: 14.97% ( 25/167) +Duyler\HttpServer\WorkerPool\Master\ConnectionQueue + Methods: 100.00% ( 8/ 8) Lines: 100.00% ( 15/ 15) +Duyler\HttpServer\WorkerPool\Master\ConnectionRouter + Methods: 50.00% ( 2/ 4) Lines: 29.73% ( 11/ 37) +Duyler\HttpServer\WorkerPool\Master\MasterFactory + Methods: 50.00% ( 2/ 4) Lines: 87.69% ( 57/ 65) +Duyler\HttpServer\WorkerPool\Master\SharedSocketMaster + Methods: 10.00% ( 1/10) Lines: 10.06% ( 16/159) +Duyler\HttpServer\WorkerPool\Master\SocketManager + Methods: 44.44% ( 4/ 9) Lines: 78.48% ( 62/ 79) +Duyler\HttpServer\WorkerPool\Master\WorkerManager + Methods: 50.00% ( 5/10) Lines: 20.45% ( 9/ 44) +Duyler\HttpServer\WorkerPool\Process\ProcessInfo + Methods: 100.00% ( 9/ 9) Lines: 100.00% ( 61/ 61) +Duyler\HttpServer\WorkerPool\Signal\SignalHandler + Methods: 11.11% ( 1/ 9) Lines: 25.00% ( 8/ 32) +Duyler\HttpServer\WorkerPool\Util\SystemInfo + Methods: 30.77% ( 4/13) Lines: 38.30% ( 36/ 94) diff --git a/examples/ai-chat.html b/examples/ai-chat.html deleted file mode 100644 index 1c71cf0..0000000 --- a/examples/ai-chat.html +++ /dev/null @@ -1,574 +0,0 @@ - - - - - - AI Chat - WebSocket Demo - - - - - -
-
-
-

AI Assistant

- Powered by WebSocket -
-
- - Connecting... -
-
- -
-
- -
Welcome to AI Chat!
-

Start a conversation with your AI assistant

-
-
- Say Hello -
-
- Tell a Story -
-
- Get Help -
-
-
- -
-
-
-
- AI is thinking... -
-
- -
-
- - -
-
-
- -
- - -
- - - - - diff --git a/examples/ai-streaming-server.php b/examples/ai-streaming-server.php deleted file mode 100644 index c2fb33c..0000000 --- a/examples/ai-streaming-server.php +++ /dev/null @@ -1,202 +0,0 @@ -on('connect', function (Connection $conn) { - echo "New connection: {$conn->getId()} from {$conn->getRemoteAddress()}\n"; - - $conn->send([ - 'type' => 'welcome', - 'message' => 'Connected to AI Streaming Server', - 'id' => $conn->getId(), - ]); -}); - -$ws->on('message', function (Connection $conn, Message $message) use (&$activeStreams) { - echo "Received message from {$conn->getId()}: {$message->getData()}\n"; - - $data = $message->getJson(); - - if ($data === null) { - $conn->send([ - 'type' => 'error', - 'message' => 'Invalid JSON', - ]); - return; - } - - if ($data['type'] === 'ai_request') { - $prompt = $data['prompt'] ?? ''; - - if (empty($prompt)) { - $conn->send([ - 'type' => 'error', - 'message' => 'Empty prompt', - ]); - return; - } - - $streamId = uniqid('stream_', true); - - $response = generateAiResponse($prompt); - - $activeStreams[$streamId] = [ - 'conn' => $conn, - 'prompt' => $prompt, - 'response' => $response, - 'position' => 0, - 'started' => microtime(true), - ]; - - $conn->send([ - 'type' => 'stream_start', - 'stream_id' => $streamId, - ]); - - echo "Started stream {$streamId} for connection {$conn->getId()}\n"; - } -}); - -$ws->on('close', function (Connection $conn, int $code, string $reason) use (&$activeStreams) { - echo "Connection {$conn->getId()} closed: $code - $reason\n"; - - foreach ($activeStreams as $streamId => $stream) { - if ($stream['conn']->getId() === $conn->getId()) { - unset($activeStreams[$streamId]); - echo "Cancelled stream {$streamId}\n"; - } - } -}); - -$ws->on('error', function (Connection $conn, Throwable $error) { - echo "Error on connection {$conn->getId()}: {$error->getMessage()}\n"; -}); - -function generateAiResponse(string $prompt): string -{ - $responses = [ - 'hello' => "Hello! I'm an AI assistant powered by WebSocket streaming. I can help you with various tasks, answer questions, and have conversations. The streaming feature allows me to respond in real-time, word by word, just like a human typing. How can I assist you today?", - - 'story' => "Once upon a time, in a digital realm far beyond the clouds, there lived an AI assistant. This assistant had a special gift - the ability to communicate through streams of consciousness, delivering thoughts as they formed. Every day, curious humans would visit, asking questions and seeking wisdom. The AI would respond, not all at once, but word by word, creating a natural flow of conversation that felt wonderfully human.", - - 'help' => "I can help you with many things! Here are some examples: I can answer questions about programming, explain complex concepts, help with creative writing, provide information on various topics, assist with problem-solving, and engage in meaningful conversations. The streaming feature you're experiencing right now makes our interaction feel more natural and responsive. Just ask me anything, and I'll do my best to help!", - - 'default' => "That's an interesting question! Let me think about it... " . $prompt . " ... Based on my understanding, I'd say that this is a complex topic with many facets. The key thing to remember is that every question deserves thoughtful consideration. In this case, we need to look at multiple perspectives and weigh different factors. The beauty of this streaming approach is that you can see my thoughts develop in real-time, creating a more engaging and interactive experience.", - ]; - - $lowerPrompt = strtolower($prompt); - - if (str_contains($lowerPrompt, 'hello') || str_contains($lowerPrompt, 'hi') || str_contains($lowerPrompt, 'who are you')) { - return $responses['hello']; - } - - if (str_contains($lowerPrompt, 'story')) { - return $responses['story']; - } - - if (str_contains($lowerPrompt, 'help') || str_contains($lowerPrompt, 'what can you')) { - return $responses['help']; - } - - return $responses['default']; -} - -function processActiveStreams(array &$activeStreams): void -{ - foreach ($activeStreams as $streamId => $stream) { - $conn = $stream['conn']; - $response = $stream['response']; - $position = $stream['position']; - - $words = preg_split('/(\s+)/', $response, -1, PREG_SPLIT_DELIM_CAPTURE); - - if ($position < count($words)) { - $chunk = $words[$position]; - - $conn->send([ - 'type' => 'stream_chunk', - 'stream_id' => $streamId, - 'chunk' => $chunk, - ]); - - $activeStreams[$streamId]['position']++; - } else { - $conn->send([ - 'type' => 'stream_end', - 'stream_id' => $streamId, - ]); - - $duration = round((microtime(true) - $stream['started']) * 1000); - echo "Completed stream {$streamId} in {$duration}ms\n"; - - unset($activeStreams[$streamId]); - } - } -} - -$server->attachWebSocket('/ws', $ws); - -if (!$server->start()) { - die("Failed to start server\n"); -} - -echo "\n"; -echo "╔════════════════════════════════════════════════════════╗\n"; -echo "║ AI Streaming Server Running ║\n"; -echo "╠════════════════════════════════════════════════════════╣\n"; -echo "║ HTTP: http://localhost:8080 ║\n"; -echo "║ WebSocket: ws://localhost:8080/ws ║\n"; -echo "║ Chat UI: Open examples/ai-chat.html in browser ║\n"; -echo "╚════════════════════════════════════════════════════════╝\n"; -echo "\n"; - -while (true) { - if ($server->hasRequest()) { - $request = $server->getRequest(); - - if ($request !== null) { - $path = $request->getUri()->getPath(); - - if ($path === '/' || $path === '/chat') { - $html = file_get_contents(__DIR__ . '/ai-chat.html'); - $response = new Response(200, ['Content-Type' => 'text/html'], $html); - $server->respond($response); - } else { - $response = new Response(404, ['Content-Type' => 'text/plain'], 'Not Found'); - $server->respond($response); - } - } - } - - processActiveStreams($activeStreams); - - usleep(50000); // 50ms between iterations (20 words per second) -} - diff --git a/examples/websocket-chat.php b/examples/websocket-chat.php deleted file mode 100644 index 5e9e345..0000000 --- a/examples/websocket-chat.php +++ /dev/null @@ -1,209 +0,0 @@ -on('connect', function (Connection $conn) { - echo "New WebSocket connection: {$conn->getId()} from {$conn->getRemoteAddress()}\n"; - - $conn->send([ - 'type' => 'welcome', - 'message' => 'Welcome to the chat!', - 'id' => $conn->getId(), - ]); -}); - -$ws->on('message', function (Connection $conn, Message $message) { - echo "Received message from {$conn->getId()}: {$message->getData()}\n"; - - $data = $message->getJson(); - - if ($data === null) { - $conn->send([ - 'type' => 'error', - 'message' => 'Invalid JSON', - ]); - return; - } - - match ($data['type'] ?? null) { - 'join' => handleJoin($conn, $data), - 'chat' => handleChat($conn, $data), - 'leave' => handleLeave($conn, $data), - default => $conn->send([ - 'type' => 'error', - 'message' => 'Unknown message type', - ]), - }; -}); - -$ws->on('close', function (Connection $conn, int $code, string $reason) { - echo "Connection {$conn->getId()} closed: $code - $reason\n"; - - if ($conn->hasData('username')) { - $conn->broadcast([ - 'type' => 'user_left', - 'username' => $conn->getData('username'), - ], excludeSelf: true); - } -}); - -$ws->on('error', function (Connection $conn, Throwable $error) { - echo "Error on connection {$conn->getId()}: {$error->getMessage()}\n"; -}); - -function handleJoin(Connection $conn, array $data): void -{ - $username = $data['username'] ?? 'Anonymous'; - - $conn->setData('username', $username); - $conn->joinRoom('chat'); - - $conn->send([ - 'type' => 'joined', - 'username' => $username, - 'users_count' => count($conn->getServer()->getRoomConnections('chat')), - ]); - - $conn->broadcast([ - 'type' => 'user_joined', - 'username' => $username, - ], excludeSelf: true); -} - -function handleChat(Connection $conn, array $data): void -{ - if (!$conn->hasData('username')) { - $conn->send([ - 'type' => 'error', - 'message' => 'You must join first', - ]); - return; - } - - $conn->sendToRoom('chat', [ - 'type' => 'message', - 'username' => $conn->getData('username'), - 'text' => $data['text'] ?? '', - 'timestamp' => time(), - ]); -} - -function handleLeave(Connection $conn, array $data): void -{ - if ($conn->hasData('username')) { - $username = $conn->getData('username'); - $conn->leaveRoom('chat'); - - $conn->send([ - 'type' => 'left', - 'message' => 'You left the chat', - ]); - - $conn->broadcast([ - 'type' => 'user_left', - 'username' => $username, - ], excludeSelf: true); - } -} - -$server->attachWebSocket('/ws', $ws); - -$server->start(); - -echo "WebSocket Chat Server running on http://0.0.0.0:8080\n"; -echo "WebSocket endpoint: ws://0.0.0.0:8080/ws\n"; - -while (true) { - if ($server->hasRequest()) { - $request = $server->getRequest(); - - if ($request !== null) { - if ($request->getUri()->getPath() === '/') { - $html = <<<'HTML' - - - - WebSocket Chat - - - -

WebSocket Chat

-
- - -

- - - - - - - -HTML; - - $response = new Response(200, ['Content-Type' => 'text/html'], $html); - $server->respond($response); - } else { - $response = new Response(404, [], 'Not Found'); - $server->respond($response); - } - } - } - - usleep(1000); -} - diff --git a/src/Config/ServerConfig.php b/src/Config/ServerConfig.php index 4ce2e3a..6db68d0 100644 --- a/src/Config/ServerConfig.php +++ b/src/Config/ServerConfig.php @@ -30,6 +30,8 @@ public function __construct( public int $rateLimitRequests = 100, public int $rateLimitWindow = 60, public int $maxAcceptsPerCycle = 10, + public int $socketBacklog = 511, + public int $headerCacheLimit = 100, public bool $debugMode = false, ) { $this->validate(); @@ -110,5 +112,14 @@ private function validate(): void if ($this->maxAcceptsPerCycle < 1) { throw new InvalidConfigException('Max accepts per cycle must be positive'); } + + if ($this->socketBacklog < 1) { + throw new InvalidConfigException('Socket backlog must be positive'); + } + + if ($this->headerCacheLimit < 1) { + throw new InvalidConfigException('Header cache limit must be positive'); + } } + } diff --git a/src/Connection/ConnectionPool.php b/src/Connection/ConnectionPool.php index 9c256e5..ad6df2d 100644 --- a/src/Connection/ConnectionPool.php +++ b/src/Connection/ConnectionPool.php @@ -41,7 +41,7 @@ public function add(Connection $connection): void return; } - $this->connections->attach($connection, time()); + $this->connections->offsetSet($connection, time()); $resourceId = $this->getSocketId($connection->getSocket()); $this->connectionsByResourceId[$resourceId] = $connection; @@ -64,8 +64,8 @@ public function remove(Connection $connection): void $this->isModifying = true; try { - if ($this->connections->contains($connection)) { - $this->connections->detach($connection); + if ($this->connections->offsetExists($connection)) { + $this->connections->offsetUnset($connection); $resourceId = $this->getSocketId($connection->getSocket()); unset($this->connectionsByResourceId[$resourceId]); @@ -137,8 +137,8 @@ public function removeTimedOut(int $timeout): int foreach ($toRemove as $connection) { $connection->close(); - if ($this->connections->contains($connection)) { - $this->connections->detach($connection); + if ($this->connections->offsetExists($connection)) { + $this->connections->offsetUnset($connection); $resourceId = $this->getSocketId($connection->getSocket()); unset($this->connectionsByResourceId[$resourceId]); @@ -170,7 +170,7 @@ public function closeAll(): void public function has(Connection $connection): bool { - return $this->connections->contains($connection); + return $this->connections->offsetExists($connection); } public function isFull(): bool diff --git a/src/Constants.php b/src/Constants.php index c69a085..0900cad 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -9,8 +9,6 @@ final class Constants public const int MIN_PORT = 1; public const int MAX_PORT = 65535; - public const int DEFAULT_LISTEN_BACKLOG = 511; - public const int SHUTDOWN_POLL_INTERVAL_MICROSECONDS = 100000; public const int MILLISECONDS_PER_SECOND = 1000; diff --git a/src/Exception/HttpServerException.php b/src/Exception/HttpServerException.php index cc5b377..a69d8a3 100644 --- a/src/Exception/HttpServerException.php +++ b/src/Exception/HttpServerException.php @@ -5,5 +5,28 @@ namespace Duyler\HttpServer\Exception; use Exception; +use Throwable; -class HttpServerException extends Exception {} +abstract class HttpServerException extends Exception +{ + protected string $errorCode = 'UNKNOWN_ERROR'; + + public function __construct( + string $message = '', + int $code = 0, + ?Throwable $previous = null, + private readonly array $context = [], + ) { + parent::__construct($message, $code, $previous); + } + + public function getErrorCode(): string + { + return $this->errorCode; + } + + public function getContext(): array + { + return $this->context; + } +} diff --git a/src/Exception/InvalidConfigException.php b/src/Exception/InvalidConfigException.php index b877bf6..9885e61 100644 --- a/src/Exception/InvalidConfigException.php +++ b/src/Exception/InvalidConfigException.php @@ -4,4 +4,7 @@ namespace Duyler\HttpServer\Exception; -class InvalidConfigException extends HttpServerException {} +class InvalidConfigException extends HttpServerException +{ + protected string $errorCode = 'INVALID_CONFIG'; +} diff --git a/src/Exception/ParseException.php b/src/Exception/ParseException.php index 0466202..1d253d4 100644 --- a/src/Exception/ParseException.php +++ b/src/Exception/ParseException.php @@ -4,4 +4,7 @@ namespace Duyler\HttpServer\Exception; -class ParseException extends HttpServerException {} +class ParseException extends HttpServerException +{ + protected string $errorCode = 'PARSE_ERROR'; +} diff --git a/src/Exception/SocketException.php b/src/Exception/SocketException.php index a205168..7feca83 100644 --- a/src/Exception/SocketException.php +++ b/src/Exception/SocketException.php @@ -4,4 +4,24 @@ namespace Duyler\HttpServer\Exception; -class SocketException extends HttpServerException {} +use Socket; + +use function socket_last_error; +use function socket_strerror; + +class SocketException extends HttpServerException +{ + protected string $errorCode = 'SOCKET_ERROR'; + + public static function fromLastError(?Socket $socket = null): self + { + $errorCode = $socket !== null ? socket_last_error($socket) : socket_last_error(); + $errorMsg = socket_strerror($errorCode); + + return new self( + message: $errorMsg, + code: $errorCode, + context: ['socket_error' => $errorCode], + ); + } +} diff --git a/src/Exception/TimeoutException.php b/src/Exception/TimeoutException.php index 63d781e..42d6d18 100644 --- a/src/Exception/TimeoutException.php +++ b/src/Exception/TimeoutException.php @@ -4,4 +4,7 @@ namespace Duyler\HttpServer\Exception; -class TimeoutException extends HttpServerException {} +class TimeoutException extends HttpServerException +{ + protected string $errorCode = 'TIMEOUT_ERROR'; +} diff --git a/src/Handler/FileDownloadHandler.php b/src/Handler/FileDownloadHandler.php index 678a59e..d35c560 100644 --- a/src/Handler/FileDownloadHandler.php +++ b/src/Handler/FileDownloadHandler.php @@ -114,18 +114,90 @@ public function downloadRange( public function parseRangeHeader(string $rangeHeader, int $fileSize): ?array { - if (!preg_match('/^bytes=(\d+)-(\d*)$/', $rangeHeader, $matches)) { + if (!str_starts_with($rangeHeader, 'bytes=')) { return null; } - $start = (int) $matches[1]; - $end = $matches[2] === '' ? $fileSize - 1 : (int) $matches[2]; + $rangeSpec = substr($rangeHeader, 6); + $ranges = explode(',', $rangeSpec); - if ($start < 0 || $start >= $fileSize || $end < $start || $end >= $fileSize) { + if (count($ranges) > 10) { + return null; + } + + $result = []; + + foreach ($ranges as $range) { + $parts = explode('-', trim($range), 2); + + if (count($parts) !== 2) { + return null; + } + + $startStr = trim($parts[0]); + $endStr = trim($parts[1]); + + $startEmpty = $startStr === ''; + $endEmpty = $endStr === ''; + + if ($startEmpty && $endEmpty) { + return null; + } + + $start = null; + $end = null; + + if (!$startEmpty) { + $start = $this->parseRangeValue($startStr); + if ($start === null) { + return null; + } + } + + if (!$endEmpty) { + $end = $this->parseRangeValue($endStr); + if ($end === null) { + return null; + } + } + + if ($startEmpty) { + $start = max(0, $fileSize - ($end ?? 0)); + $end = $fileSize - 1; + } elseif ($endEmpty) { + $end = $fileSize - 1; + } + + assert(null !== $start && null !== $end); + + if ($start > $end || $start >= $fileSize) { + continue; + } + + $end = min($end, $fileSize - 1); + $result[] = ['start' => $start, 'end' => $end]; + } + + return $result === [] ? null : $result; + } + + private function parseRangeValue(string $value): ?int + { + if (!preg_match('/^\d+$/', $value)) { + return null; + } + + if (strlen($value) > 19) { + return null; + } + + $intVal = (int) $value; + + if ($intVal < 0 || (string) $intVal !== $value) { return null; } - return ['start' => $start, 'end' => $end]; + return $intVal; } private function guessMimeType(string $filePath): string diff --git a/src/Handler/StaticFileHandler.php b/src/Handler/StaticFileHandler.php index 7440d36..fc102f6 100644 --- a/src/Handler/StaticFileHandler.php +++ b/src/Handler/StaticFileHandler.php @@ -7,6 +7,7 @@ use Nyholm\Psr7\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use stdClass; class StaticFileHandler { @@ -33,9 +34,13 @@ class StaticFileHandler 'otf' => 'font/otf', ]; - /** @var array */ + /** @var array */ private array $cache = []; private int $cacheSize = 0; + /** @var object|null LRU list head (most recently used) */ + private ?object $lruHead = null; + /** @var object|null LRU list tail (least recently used) */ + private ?object $lruTail = null; public function __construct( private readonly string $publicPath, @@ -176,10 +181,11 @@ private function getFileContent(string $filePath, int $mtime, string $etag, int $cached = $this->cache[$filePath]; if ($cached['mtime'] === $mtime && $cached['etag'] === $etag) { - $this->cache[$filePath]['lastAccessTime'] = microtime(true); + $this->moveToHead($cached['lruNode']); return $cached['content']; } + $this->removeFromList($cached['lruNode']); $this->cacheSize -= $cached['size']; unset($this->cache[$filePath]); } @@ -196,12 +202,14 @@ private function getFileContent(string $filePath, int $mtime, string $etag, int $this->evictIfNeeded($filesize); + $lruNode = $this->createLruNode($filePath); + $this->cache[$filePath] = [ 'content' => $content, 'mtime' => $mtime, 'etag' => $etag, - 'lastAccessTime' => microtime(true), 'size' => $filesize, + 'lruNode' => $lruNode, ]; $this->cacheSize += $filesize; @@ -221,22 +229,84 @@ private function evictIfNeeded(int $newFileSize): void private function evictLeastRecentlyUsed(): void { - $oldestPath = null; - $oldestTime = PHP_FLOAT_MAX; - - foreach ($this->cache as $path => $entry) { - if ($entry['lastAccessTime'] < $oldestTime) { - $oldestTime = $entry['lastAccessTime']; - $oldestPath = $path; - } + if (null === $this->lruTail) { + return; } - if ($oldestPath !== null) { + /** @var string $oldestPath */ + $oldestPath = $this->lruTail->path; + $this->removeFromList($this->lruTail); + + if (isset($this->cache[$oldestPath])) { $this->cacheSize -= $this->cache[$oldestPath]['size']; unset($this->cache[$oldestPath]); } } + private function createLruNode(string $path): object + { + $node = new stdClass(); + $node->path = $path; + $node->prev = null; + $node->next = null; + + if (null === $this->lruHead) { + $this->lruHead = $node; + $this->lruTail = $node; + } else { + $node->next = $this->lruHead; + $this->lruHead->prev = $node; + $this->lruHead = $node; + } + + return $node; + } + + private function moveToHead(object $node): void + { + if ($node === $this->lruHead) { + return; + } + + $this->removeFromList($node); + + $node->prev = null; + $node->next = $this->lruHead; + + if (null !== $this->lruHead) { + $this->lruHead->prev = $node; + } + + $this->lruHead = $node; + + if (null === $this->lruTail) { + $this->lruTail = $node; + } + } + + private function removeFromList(object $node): void + { + /** @var object|null $prev */ + $prev = $node->prev; + /** @var object|null $next */ + $next = $node->next; + + if (null !== $prev) { + $prev->next = $next; + } else { + $this->lruHead = $next; + } + + if (null !== $next) { + $next->prev = $prev; + } else { + $this->lruTail = $prev; + } + + $node->prev = null; + $node->next = null; + } + private function getMimeType(string $filePath): string { $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); @@ -261,5 +331,7 @@ public function clearCache(): void { $this->cache = []; $this->cacheSize = 0; + $this->lruHead = null; + $this->lruTail = null; } } diff --git a/src/Parser/HttpParser.php b/src/Parser/HttpParser.php index c53f6dc..477a7d7 100644 --- a/src/Parser/HttpParser.php +++ b/src/Parser/HttpParser.php @@ -36,6 +36,13 @@ class HttpParser /** @var array */ private static array $headerNameCache = []; + private static int $headerCacheLimit = 100; + + public function __construct(int $headerCacheLimit = 100) + { + self::$headerCacheLimit = $headerCacheLimit; + } + /** * @return array{method: string, uri: string, version: string} */ @@ -225,7 +232,7 @@ private function normalizeHeaderName(string $name): string $normalized = str_replace(' ', '-', ucwords(str_replace('-', ' ', strtolower($name)))); // Limit cache size to prevent memory leaks - if (count(self::$headerNameCache) < 100) { + if (count(self::$headerNameCache) < self::$headerCacheLimit) { self::$headerNameCache[$name] = $normalized; } diff --git a/src/Parser/RequestParser.php b/src/Parser/RequestParser.php index 16eafe8..9106a57 100644 --- a/src/Parser/RequestParser.php +++ b/src/Parser/RequestParser.php @@ -89,7 +89,12 @@ private function parseCookies(ServerRequestInterface $request, array $headers): foreach ($pairs as $pair) { $parts = explode('=', trim($pair), 2); if (count($parts) === 2) { - $cookies[$parts[0]] = urldecode($parts[1]); + $name = trim($parts[0]); + $value = urldecode($parts[1]); + + if ($this->isValidCookieName($name) && $this->isValidCookieValue($value)) { + $cookies[$name] = $value; + } } } } @@ -97,6 +102,26 @@ private function parseCookies(ServerRequestInterface $request, array $headers): return $request->withCookieParams($cookies); } + private function isValidCookieName(string $name): bool + { + if ($name === '') { + return false; + } + + return preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name) === 1; + } + + private function isValidCookieValue(string $value): bool + { + $length = strlen($value); + + if ($length > 4096) { + return false; + } + + return !preg_match('/[\x00-\x1F\x7F]/', $value); + } + /** * @param array> $headers */ diff --git a/src/RateLimit/RateLimiter.php b/src/RateLimit/RateLimiter.php index 5518776..14a6aa8 100644 --- a/src/RateLimit/RateLimiter.php +++ b/src/RateLimit/RateLimiter.php @@ -9,13 +9,22 @@ class RateLimiter /** @var array> */ private array $requests = []; + private int $callCount = 0; + public function __construct( private readonly int $maxRequests = 100, private readonly int $windowSeconds = 60, + private readonly int $cleanupInterval = 100, ) {} public function isAllowed(string $identifier): bool { + $this->callCount++; + + if ($this->callCount % $this->cleanupInterval === 0) { + $this->cleanup(); + } + $now = microtime(true); $windowStart = $now - (float) $this->windowSeconds; @@ -87,13 +96,14 @@ public function cleanup(): void } /** - * @return array{max_requests: int, window_seconds: int} + * @return array{max_requests: int, window_seconds: int, cleanup_interval: int} */ public function getConfig(): array { return [ 'max_requests' => $this->maxRequests, 'window_seconds' => $this->windowSeconds, + 'cleanup_interval' => $this->cleanupInterval, ]; } diff --git a/src/Server.php b/src/Server.php index e1cde19..ff8b17a 100644 --- a/src/Server.php +++ b/src/Server.php @@ -8,7 +8,7 @@ use Duyler\HttpServer\Config\ServerMode; use Duyler\HttpServer\Connection\Connection; use Duyler\HttpServer\Connection\ConnectionPool; -use Duyler\HttpServer\Exception\HttpServerException; +use Duyler\HttpServer\Exception\InvalidConfigException; use Duyler\HttpServer\Handler\StaticFileHandler; use Duyler\HttpServer\Metrics\ServerMetrics; use Duyler\HttpServer\Parser\HttpParser; @@ -78,7 +78,7 @@ public function __construct( private readonly ServerConfig $config, private LoggerInterface $logger = new NullLogger(), ) { - $this->httpParser = new HttpParser(); + $this->httpParser = new HttpParser($this->config->headerCacheLimit); $psr17Factory = new Psr17Factory(); $this->tempFileManager = new TempFileManager(); $this->requestParser = new RequestParser($this->httpParser, $psr17Factory, $this->tempFileManager); @@ -135,7 +135,7 @@ public function start(): bool try { $this->socket = $this->createSocket(); $this->socket->bind($this->config->host, $this->config->port); - $this->socket->listen(); + $this->socket->listen($this->config->socketBacklog); $this->socket->setBlocking(false); $this->isRunning = true; @@ -277,8 +277,7 @@ public function reset(): void } $this->isRunning = false; - - $this->logger->info('Server state reset complete'); + $this->fibers = []; } #[Override] @@ -309,7 +308,12 @@ public function hasRequest(): bool // Resume all registered Fibers before processing // This is used in Event-Driven Worker Pool mode to accept // connections from Master in background - foreach ($this->fibers as $fiber) { + foreach ($this->fibers as $key => $fiber) { + if ($fiber->isTerminated()) { + unset($this->fibers[$key]); + continue; + } + if ($fiber->isSuspended()) { try { $fiber->resume(); @@ -322,6 +326,9 @@ public function hasRequest(): bool } } + // Re-index fibers array after cleanup + $this->fibers = array_values($this->fibers); + if (!$this->isRunning) { $this->logger->warning('hasRequest() called but server is not running'); return false; @@ -463,7 +470,7 @@ private function createSocket(): SocketInterface $key = $this->config->sslKey; if ($cert === null || $key === null) { - throw new HttpServerException('SSL enabled but certificate or key not provided'); + throw new InvalidConfigException('SSL enabled but certificate or key not provided'); } return new SslSocket( @@ -825,7 +832,14 @@ private function handleWebSocketHandshake(Connection $connection, ServerRequestI return; } + $config = $wsServer->getConfig(); + + if (Handshake::isInsecureConfig($config)) { + $this->logger->warning('WebSocket insecure configuration detected: validateOrigin is disabled with wildcard allowedOrigins', [ + 'path' => $path, + ]); + } if (!Handshake::validateOrigin($request, $config)) { $this->logger->warning('WebSocket origin validation failed', [ 'origin' => $request->getHeaderLine('Origin'), @@ -988,7 +1002,7 @@ private function handleSignal(int $signal): void public function addExternalConnection(Socket $clientSocket, array $metadata): void { if (!isset($metadata['worker_id'])) { - throw new HttpServerException('worker_id is required in metadata for addExternalConnection()'); + throw new InvalidConfigException('worker_id is required in metadata for addExternalConnection()'); } $workerContext = ['worker_id' => $metadata['worker_id']]; @@ -1081,6 +1095,26 @@ public function registerFiber(Fiber $fiber): void ]); } + #[Override] + public function unregisterFiber(Fiber $fiber): bool + { + $key = array_search($fiber, $this->fibers, true); + + if (false !== $key) { + unset($this->fibers[$key]); + $this->fibers = array_values($this->fibers); + + $this->logger->debug('Fiber unregistered', [ + 'total_fibers' => count($this->fibers), + 'worker_id' => $this->workerId, + ]); + + return true; + } + + return false; + } + private function getSocketId(SocketResourceInterface $socket): int { return spl_object_id($socket); diff --git a/src/ServerInterface.php b/src/ServerInterface.php index 766ba11..e5ac703 100644 --- a/src/ServerInterface.php +++ b/src/ServerInterface.php @@ -70,4 +70,12 @@ public function setWorkerId(int $workerId): void; * on each hasRequest() call. */ public function registerFiber(Fiber $fiber): void; + + /** + * Unregister a previously registered Fiber + * + * Removes the Fiber from the internal registry. Returns true if the + * Fiber was found and removed, false otherwise. + */ + public function unregisterFiber(Fiber $fiber): bool; } diff --git a/src/Socket/SocketErrorSuppressor.php b/src/Socket/SocketErrorSuppressor.php index c6191a6..6ab2e98 100644 --- a/src/Socket/SocketErrorSuppressor.php +++ b/src/Socket/SocketErrorSuppressor.php @@ -15,9 +15,7 @@ trait SocketErrorSuppressor */ private function suppressSocketWarnings(Closure $callback): mixed { - $previousHandler = set_error_handler(static function (int $errno, string $errstr): bool { - return true; - }, E_WARNING); + $previousHandler = set_error_handler(static fn(int $errno, string $errstr): bool => true, E_WARNING); try { return $callback(); diff --git a/src/Socket/SocketInterface.php b/src/Socket/SocketInterface.php index 39409b7..176f455 100644 --- a/src/Socket/SocketInterface.php +++ b/src/Socket/SocketInterface.php @@ -4,13 +4,11 @@ namespace Duyler\HttpServer\Socket; -use Duyler\HttpServer\Constants; - interface SocketInterface extends SocketResourceInterface { public function bind(string $address, int $port): void; - public function listen(int $backlog = Constants::DEFAULT_LISTEN_BACKLOG): void; + public function listen(int $backlog = 511): void; public function accept(): SocketResourceInterface|false; } diff --git a/src/Socket/SslSocket.php b/src/Socket/SslSocket.php index 2f727e0..cfa8b02 100644 --- a/src/Socket/SslSocket.php +++ b/src/Socket/SslSocket.php @@ -4,7 +4,6 @@ namespace Duyler\HttpServer\Socket; -use Duyler\HttpServer\Constants; use Duyler\HttpServer\Exception\SocketException; use Override; @@ -57,7 +56,7 @@ public function bind(string $address, int $port): void } #[Override] - public function listen(int $backlog = Constants::DEFAULT_LISTEN_BACKLOG): void + public function listen(int $backlog = 511): void { if (!$this->isBound) { throw new SocketException('SSL socket is already listening after bind'); diff --git a/src/Socket/StreamSocket.php b/src/Socket/StreamSocket.php index 2036541..59eec27 100644 --- a/src/Socket/StreamSocket.php +++ b/src/Socket/StreamSocket.php @@ -4,7 +4,6 @@ namespace Duyler\HttpServer\Socket; -use Duyler\HttpServer\Constants; use Duyler\HttpServer\Exception\SocketException; use Override; use Socket; @@ -55,7 +54,7 @@ public function bind(string $address, int $port): void } #[Override] - public function listen(int $backlog = Constants::DEFAULT_LISTEN_BACKLOG): void + public function listen(int $backlog = 511): void { if (!$this->isBound) { throw new SocketException('Socket must be bound before listening'); diff --git a/src/Socket/StreamSocketResource.php b/src/Socket/StreamSocketResource.php index a3f47ee..b9a32f0 100644 --- a/src/Socket/StreamSocketResource.php +++ b/src/Socket/StreamSocketResource.php @@ -7,6 +7,8 @@ use Duyler\HttpServer\Exception\SocketException; use InvalidArgumentException; use Override; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Socket; use Throwable; @@ -22,8 +24,10 @@ final class StreamSocketResource implements SocketResourceInterface /** * @param Socket|resource $resource */ - public function __construct(mixed $resource) - { + public function __construct( + mixed $resource, + private readonly LoggerInterface $logger = new NullLogger(), + ) { if (!is_resource($resource) && !$resource instanceof Socket) { throw new InvalidArgumentException('Invalid socket resource or Socket object'); } @@ -86,7 +90,11 @@ public function close(): void $this->resource = null; fclose($resource); } - } catch (Throwable) { + } catch (Throwable $e) { + $this->logger->debug('Error closing socket resource', [ + 'error' => $e->getMessage(), + 'code' => $e->getCode(), + ]); } $this->resource = null; diff --git a/src/WebSocket/Exception/InvalidWebSocketConfigException.php b/src/WebSocket/Exception/InvalidWebSocketConfigException.php index 77a63d0..ad61cbf 100644 --- a/src/WebSocket/Exception/InvalidWebSocketConfigException.php +++ b/src/WebSocket/Exception/InvalidWebSocketConfigException.php @@ -4,6 +4,9 @@ namespace Duyler\HttpServer\WebSocket\Exception; -use InvalidArgumentException; +use Duyler\HttpServer\Exception\InvalidConfigException; -class InvalidWebSocketConfigException extends InvalidArgumentException {} +class InvalidWebSocketConfigException extends InvalidConfigException +{ + protected string $errorCode = 'INVALID_WEBSOCKET_CONFIG'; +} diff --git a/src/WebSocket/Exception/InvalidWebSocketFrameException.php b/src/WebSocket/Exception/InvalidWebSocketFrameException.php index d0796ba..53d9b1d 100644 --- a/src/WebSocket/Exception/InvalidWebSocketFrameException.php +++ b/src/WebSocket/Exception/InvalidWebSocketFrameException.php @@ -4,6 +4,9 @@ namespace Duyler\HttpServer\WebSocket\Exception; -use RuntimeException; +use Duyler\HttpServer\Exception\HttpServerException; -class InvalidWebSocketFrameException extends RuntimeException {} +class InvalidWebSocketFrameException extends HttpServerException +{ + protected string $errorCode = 'INVALID_WEBSOCKET_FRAME'; +} diff --git a/src/WebSocket/Frame.php b/src/WebSocket/Frame.php index 4d39223..388eccb 100644 --- a/src/WebSocket/Frame.php +++ b/src/WebSocket/Frame.php @@ -16,12 +16,14 @@ public function __construct( public readonly bool $masked = false, public readonly ?string $maskingKey = null, ) { - if ($this->masked && $this->maskingKey === null) { - throw new InvalidWebSocketFrameException('Masked frame must have masking key'); - } + if ($this->masked) { + if ($this->maskingKey === null) { + throw new InvalidWebSocketFrameException('Masked frame must have masking key'); + } - if ($this->masked && strlen($this->maskingKey) !== 4) { - throw new InvalidWebSocketFrameException('Masking key must be exactly 4 bytes'); + if (strlen($this->maskingKey) !== 4) { + throw new InvalidWebSocketFrameException('Masking key must be exactly 4 bytes'); + } } } diff --git a/src/WebSocket/Handshake.php b/src/WebSocket/Handshake.php index 33c9bc9..e9e8915 100644 --- a/src/WebSocket/Handshake.php +++ b/src/WebSocket/Handshake.php @@ -98,6 +98,16 @@ public static function validateOrigin(ServerRequestInterface $request, WebSocket return in_array($origin, $config->allowedOrigins, true); } + + public static function isInsecureConfig(WebSocketConfig $config): bool + { + if ($config->validateOrigin) { + return false; + } + + return in_array('*', $config->allowedOrigins, true); + } + /** * @param array $requestedProtocols * @param array $supportedProtocols diff --git a/src/WebSocket/Message.php b/src/WebSocket/Message.php index 9e34ab0..6848ccb 100644 --- a/src/WebSocket/Message.php +++ b/src/WebSocket/Message.php @@ -6,12 +6,15 @@ use Duyler\HttpServer\WebSocket\Enum\Opcode; use JsonException; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; class Message { public function __construct( private readonly string $data, private readonly Opcode $opcode, + private readonly LoggerInterface $logger = new NullLogger(), ) {} public function getData(): string @@ -45,8 +48,21 @@ public function getJson(): ?array try { $decoded = json_decode($this->data, true, 512, JSON_THROW_ON_ERROR); - return is_array($decoded) ? $decoded : null; - } catch (JsonException) { + + if (!is_array($decoded)) { + $this->logger->debug('WebSocket JSON message is not an array', [ + 'type' => gettype($decoded), + ]); + return null; + } + + return $decoded; + } catch (JsonException $e) { + $this->logger->debug('Failed to parse WebSocket message as JSON', [ + 'error' => $e->getMessage(), + 'payload_length' => strlen($this->data), + 'opcode' => $this->opcode->name, + ]); return null; } } diff --git a/src/WebSocket/WebSocketConfig.php b/src/WebSocket/WebSocketConfig.php index 304cdfb..85ee5f7 100644 --- a/src/WebSocket/WebSocketConfig.php +++ b/src/WebSocket/WebSocketConfig.php @@ -31,7 +31,7 @@ public function __construct( public int $handshakeTimeout = 5, public int $closeTimeout = 5, array $allowedOrigins = ['*'], - public bool $validateOrigin = false, + public bool $validateOrigin = true, public bool $requireMasking = true, public bool $autoFragmentation = true, public int $writeBufferSize = 8192, diff --git a/src/WorkerPool/Balancer/BalancerInterface.php b/src/WorkerPool/Balancer/BalancerInterface.php index af73676..2975181 100644 --- a/src/WorkerPool/Balancer/BalancerInterface.php +++ b/src/WorkerPool/Balancer/BalancerInterface.php @@ -24,6 +24,11 @@ public function onConnectionEstablished(int $workerId): void; */ public function onConnectionClosed(int $workerId): void; + /** + * Notify balancer that worker was removed + */ + public function onWorkerRemoved(int $workerId): void; + /** * Reset balancer state */ diff --git a/src/WorkerPool/Balancer/LeastConnectionsBalancer.php b/src/WorkerPool/Balancer/LeastConnectionsBalancer.php index 776dbf9..1897e50 100644 --- a/src/WorkerPool/Balancer/LeastConnectionsBalancer.php +++ b/src/WorkerPool/Balancer/LeastConnectionsBalancer.php @@ -58,6 +58,12 @@ public function reset(): void $this->connections = []; } + #[Override] + public function onWorkerRemoved(int $workerId): void + { + unset($this->connections[$workerId]); + } + /** * @return array */ diff --git a/src/WorkerPool/Balancer/RoundRobinBalancer.php b/src/WorkerPool/Balancer/RoundRobinBalancer.php index 5110ff2..11701ec 100644 --- a/src/WorkerPool/Balancer/RoundRobinBalancer.php +++ b/src/WorkerPool/Balancer/RoundRobinBalancer.php @@ -47,6 +47,16 @@ public function reset(): void $this->workerIds = []; } + #[Override] + public function onWorkerRemoved(int $workerId): void + { + $index = array_search($workerId, $this->workerIds, true); + + if ($index !== false && $index < $this->currentIndex) { + $this->currentIndex--; + } + } + public function getCurrentIndex(): int { return $this->currentIndex; diff --git a/src/WorkerPool/Config/BalancerType.php b/src/WorkerPool/Config/BalancerType.php new file mode 100644 index 0000000..aeecdae --- /dev/null +++ b/src/WorkerPool/Config/BalancerType.php @@ -0,0 +1,12 @@ +maxIpcMessageSize < 1024) { + throw new InvalidArgumentException('Max IPC message size must be at least 1024 bytes'); + } if ($this->fallbackCpuCores < 1) { throw new InvalidArgumentException('Fallback CPU cores must be positive'); } diff --git a/src/WorkerPool/Exception/IPCException.php b/src/WorkerPool/Exception/IPCException.php index 3f5b644..f9fe3be 100644 --- a/src/WorkerPool/Exception/IPCException.php +++ b/src/WorkerPool/Exception/IPCException.php @@ -4,6 +4,9 @@ namespace Duyler\HttpServer\WorkerPool\Exception; -use RuntimeException; +use Duyler\HttpServer\Exception\HttpServerException; -class IPCException extends RuntimeException {} +class IPCException extends HttpServerException +{ + protected string $errorCode = 'IPC_ERROR'; +} diff --git a/src/WorkerPool/Exception/WorkerPoolException.php b/src/WorkerPool/Exception/WorkerPoolException.php index aee4d05..ea28b57 100644 --- a/src/WorkerPool/Exception/WorkerPoolException.php +++ b/src/WorkerPool/Exception/WorkerPoolException.php @@ -4,6 +4,9 @@ namespace Duyler\HttpServer\WorkerPool\Exception; -use RuntimeException; +use Duyler\HttpServer\Exception\HttpServerException; -class WorkerPoolException extends RuntimeException {} +class WorkerPoolException extends HttpServerException +{ + protected string $errorCode = 'WORKER_POOL_ERROR'; +} diff --git a/src/WorkerPool/IPC/FdPasser.php b/src/WorkerPool/IPC/FdPasser.php index e48f53a..f930b8c 100644 --- a/src/WorkerPool/IPC/FdPasser.php +++ b/src/WorkerPool/IPC/FdPasser.php @@ -163,7 +163,10 @@ public function receiveFd(Socket $controlSocket): ?array try { $metadata = json_decode($metadataJson, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { - $this->logger->error('Failed to decode metadata', ['error' => $e->getMessage()]); + $this->logger->error('Failed to decode IPC metadata JSON', [ + 'error' => $e->getMessage(), + 'json_length' => strlen($metadataJson), + ]); $metadata = []; } diff --git a/src/WorkerPool/IPC/Message.php b/src/WorkerPool/IPC/Message.php index 8dcfed0..6e1d0b8 100644 --- a/src/WorkerPool/IPC/Message.php +++ b/src/WorkerPool/IPC/Message.php @@ -6,6 +6,8 @@ use InvalidArgumentException; use JsonException; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; readonly class Message { @@ -31,22 +33,31 @@ public function serialize(): string ], JSON_THROW_ON_ERROR); } - public static function unserialize(string $data): self + public static function unserialize(string $data, ?LoggerInterface $logger = null): self { + $logger ??= new NullLogger(); + try { $decoded = json_decode($data, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { + $logger->warning('Failed to unserialize IPC message: JSON parse error', [ + 'error' => $e->getMessage(), + 'data_length' => strlen($data), + ]); throw new InvalidArgumentException('Invalid message format: ' . $e->getMessage(), 0, $e); } if (!is_array($decoded)) { + $logger->warning('Failed to unserialize IPC message: decoded data is not an array', [ + 'type' => gettype($decoded), + ]); throw new InvalidArgumentException('Invalid message format'); } if (!isset($decoded['type'])) { + $logger->warning('Failed to unserialize IPC message: missing type field'); throw new InvalidArgumentException('Message type is required'); } - assert(is_int($decoded['type']) || is_string($decoded['type'])); assert(!isset($decoded['data']) || is_array($decoded['data'])); assert(!isset($decoded['timestamp']) || is_float($decoded['timestamp']) || is_int($decoded['timestamp']) || is_null($decoded['timestamp'])); diff --git a/src/WorkerPool/IPC/UnixSocketChannel.php b/src/WorkerPool/IPC/UnixSocketChannel.php index 03adaa0..5cb681f 100644 --- a/src/WorkerPool/IPC/UnixSocketChannel.php +++ b/src/WorkerPool/IPC/UnixSocketChannel.php @@ -12,7 +12,11 @@ class UnixSocketChannel private ?Socket $socket = null; private bool $isConnected = false; - public function __construct(private readonly string $socketPath, private readonly bool $isServer = false) {} + public function __construct( + private readonly string $socketPath, + private readonly bool $isServer = false, + private readonly int $maxIpcMessageSize = 1048576, + ) {} public function connect(): bool { @@ -102,7 +106,7 @@ public function receive(): ?Message $length = $unpacked[1]; assert(is_int($length)); - if ($length === 0 || $length > 1048576) { + if ($length === 0 || $length > $this->maxIpcMessageSize) { throw new IPCException('Invalid message length: ' . $length); } diff --git a/src/WorkerPool/Master/CentralizedMaster.php b/src/WorkerPool/Master/CentralizedMaster.php index 1ca69ef..31241dd 100644 --- a/src/WorkerPool/Master/CentralizedMaster.php +++ b/src/WorkerPool/Master/CentralizedMaster.php @@ -181,7 +181,8 @@ private function acceptConnections(): void return; } - for ($i = 0; $i < 10; $i++) { + $maxAccepts = $this->serverConfig?->maxAcceptsPerCycle ?? 10; + for ($i = 0; $i < $maxAccepts; $i++) { $clientSocket = $this->socketManager->accept(); if ($clientSocket === null) { @@ -222,6 +223,31 @@ private function distributeConnections(): void } } + #[Override] + protected function checkWorkers(): void + { + foreach ($this->workers as $workerId => $worker) { + $result = pcntl_waitpid($worker->pid, $status, WNOHANG); + + if ($result === $worker->pid) { + $this->logger->warning('Worker died', [ + 'worker_id' => $workerId, + 'pid' => $worker->pid, + ]); + + $this->balancer->onWorkerRemoved($workerId); + unset($this->workers[$workerId], $this->workerSockets[$workerId]); + + if ($this->config->autoRestart && !$this->shouldStop) { + $this->logger->info('Respawning worker', ['worker_id' => $workerId]); + sleep($this->config->restartDelay); + $this->spawnWorker($workerId); + } + } + } + } + + #[Override] protected function spawnWorker(int $workerId): void { diff --git a/src/WorkerPool/Master/SharedSocketMaster.php b/src/WorkerPool/Master/SharedSocketMaster.php index 7340b94..52ff47c 100644 --- a/src/WorkerPool/Master/SharedSocketMaster.php +++ b/src/WorkerPool/Master/SharedSocketMaster.php @@ -194,7 +194,7 @@ private function createSharedSocket(int $workerId): Socket throw new WorkerPoolException("Failed to bind socket: $error"); } - if (!socket_listen($socket, 128)) { + if (!socket_listen($socket, $this->serverConfig->socketBacklog)) { $this->logger->error('Failed to listen', [ 'worker_id' => $workerId, 'error' => socket_strerror(socket_last_error($socket)), @@ -212,8 +212,6 @@ private function createSharedSocket(int $workerId): Socket 'port' => $port, ]); - socket_set_nonblock($socket); - return $socket; } @@ -296,7 +294,7 @@ private function runCallbackWorker(int $workerId): void exit(1); } - if (!socket_listen($socket, 128)) { + if (!socket_listen($socket, $this->serverConfig->socketBacklog)) { $this->logger->error('Failed to listen', [ 'worker_id' => $workerId, 'error' => socket_strerror(socket_last_error($socket)), diff --git a/src/WorkerPool/Master/SocketManager.php b/src/WorkerPool/Master/SocketManager.php index b5469d2..f6c480f 100644 --- a/src/WorkerPool/Master/SocketManager.php +++ b/src/WorkerPool/Master/SocketManager.php @@ -66,10 +66,14 @@ public function listen(): void ); } - $backlog = 128; - $this->logger->debug('Starting to listen', ['backlog' => $backlog]); - if (!socket_listen($this->masterSocket, $backlog)) { - throw new WorkerPoolException('Failed to listen: ' . socket_strerror(socket_last_error($this->masterSocket))); + $this->logger->debug('Starting to listen', ['backlog' => $this->config->socketBacklog]); + if (!socket_listen($this->masterSocket, $this->config->socketBacklog)) { + throw new WorkerPoolException( + sprintf( + 'Failed to listen on socket: %s', + socket_strerror(socket_last_error($this->masterSocket)), + ), + ); } $this->logger->debug('Setting non-blocking mode'); diff --git a/tests/Unit/Config/ServerConfigTest.php b/tests/Unit/Config/ServerConfigTest.php index d56e7b2..9842c35 100644 --- a/tests/Unit/Config/ServerConfigTest.php +++ b/tests/Unit/Config/ServerConfigTest.php @@ -60,4 +60,89 @@ public function accepts_large_max_accepts_per_cycle(): void $this->assertSame(1000, $config->maxAcceptsPerCycle); } + + + #[Test] + public function default_socket_backlog(): void + { + $config = new ServerConfig(); + + $this->assertSame(511, $config->socketBacklog); + } + + #[Test] + public function custom_socket_backlog(): void + { + $config = new ServerConfig(socketBacklog: 1024); + + $this->assertSame(1024, $config->socketBacklog); + } + + #[Test] + public function rejects_zero_socket_backlog(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Socket backlog must be positive'); + + new ServerConfig(socketBacklog: 0); + } + + #[Test] + public function rejects_negative_socket_backlog(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Socket backlog must be positive'); + + new ServerConfig(socketBacklog: -1); + } + + #[Test] + public function accepts_one_socket_backlog(): void + { + $config = new ServerConfig(socketBacklog: 1); + + $this->assertSame(1, $config->socketBacklog); + } + + #[Test] + public function default_header_cache_limit(): void + { + $config = new ServerConfig(); + + $this->assertSame(100, $config->headerCacheLimit); + } + + #[Test] + public function custom_header_cache_limit(): void + { + $config = new ServerConfig(headerCacheLimit: 500); + + $this->assertSame(500, $config->headerCacheLimit); + } + + #[Test] + public function rejects_zero_header_cache_limit(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Header cache limit must be positive'); + + new ServerConfig(headerCacheLimit: 0); + } + + #[Test] + public function rejects_negative_header_cache_limit(): void + { + $this->expectException(InvalidConfigException::class); + $this->expectExceptionMessage('Header cache limit must be positive'); + + new ServerConfig(headerCacheLimit: -1); + } + + #[Test] + public function accepts_one_header_cache_limit(): void + { + $config = new ServerConfig(headerCacheLimit: 1); + + $this->assertSame(1, $config->headerCacheLimit); + } } diff --git a/tests/Unit/Exception/ExceptionHierarchyTest.php b/tests/Unit/Exception/ExceptionHierarchyTest.php new file mode 100644 index 0000000..6e8534e --- /dev/null +++ b/tests/Unit/Exception/ExceptionHierarchyTest.php @@ -0,0 +1,257 @@ +getParentClass(); + + $inheritsFromHttpServerException = false; + while (null !== $parentClass) { + if (HttpServerException::class === $parentClass->getName()) { + $inheritsFromHttpServerException = true; + break; + } + $parentClass = $parentClass->getParentClass(); + } + + $this->assertTrue( + $inheritsFromHttpServerException, + "{$exceptionClass} should inherit from HttpServerException", + ); + } + } + + #[Test] + public function all_exceptions_have_unique_error_codes(): void + { + $exceptionClasses = [ + SocketException::class, + ParseException::class, + InvalidConfigException::class, + TimeoutException::class, + WorkerPoolException::class, + IPCException::class, + InvalidWebSocketConfigException::class, + InvalidWebSocketFrameException::class, + ]; + + $errorCodes = []; + foreach ($exceptionClasses as $exceptionClass) { + $reflection = new ReflectionClass($exceptionClass); + $exception = $reflection->newInstanceWithoutConstructor(); + + $errorCode = $exception->getErrorCode(); + + $this->assertNotEmpty($errorCode, "{$exceptionClass} should have an error code"); + + if (array_key_exists($errorCode, $errorCodes)) { + $this->fail("Error code '{$errorCode}' from {$exceptionClass} is not unique (already used by {$errorCodes[$errorCode]})"); + } + + $errorCodes[$errorCode] = $exceptionClass; + } + } + #[Test] + public function http_server_exception_is_abstract(): void + { + $reflection = new ReflectionClass(HttpServerException::class); + + $this->assertTrue($reflection->isAbstract(), 'HttpServerException should be abstract'); + } + + #[Test] + public function http_server_exception_extends_exception(): void + { + $reflection = new ReflectionClass(HttpServerException::class); + $parent = $reflection->getParentClass(); + + $this->assertNotFalse($parent); + $this->assertSame(Exception::class, $parent->getName()); + } + + #[Test] + public function socket_exception_has_from_last_error_factory(): void + { + $reflection = new ReflectionClass(SocketException::class); + + $this->assertTrue($reflection->hasMethod('fromLastError'), 'SocketException should have fromLastError factory method'); + } + + #[Test] + public function exception_has_context_parameter(): void + { + $exception = new SocketException('Test message', 0, null, ['key' => 'value']); + + $this->assertSame(['key' => 'value'], $exception->getContext()); + } + + #[Test] + public function exception_context_defaults_to_empty_array(): void + { + $reflection = new ReflectionClass(SocketException::class); + $constructor = $reflection->getConstructor(); + $contextParam = $constructor->getParameters()[3] ?? null; + + $this->assertNotNull($contextParam); + $this->assertSame('context', $contextParam->getName()); + $this->assertTrue($contextParam->isDefaultValueAvailable()); + $this->assertSame([], $contextParam->getDefaultValue()); + } + + #[Test] + public function get_error_code_returns_string(): void + { + $exception = new SocketException('Test message'); + + $this->assertIsString($exception->getErrorCode()); + } + + #[Test] + public function get_context_returns_array(): void + { + $exception = new SocketException('Test message'); + + $this->assertIsArray($exception->getContext()); + } + + #[Test] + public function invalid_web_socket_config_exception_extends_invalid_config_exception(): void + { + $reflection = new ReflectionClass(InvalidWebSocketConfigException::class); + $parent = $reflection->getParentClass(); + + $this->assertNotFalse($parent); + $this->assertSame(InvalidConfigException::class, $parent->getName()); + } + + #[Test] + public function catch_all_http_server_exceptions_works(): void + { + $exceptions = [ + new SocketException('socket'), + new ParseException('parse'), + new InvalidConfigException('config'), + new TimeoutException('timeout'), + new WorkerPoolException('worker'), + new IPCException('ipc'), + new InvalidWebSocketConfigException('websocket config'), + new InvalidWebSocketFrameException('websocket frame'), + ]; + + foreach ($exceptions as $exception) { + $caught = false; + try { + throw $exception; + } catch (HttpServerException) { + $caught = true; + } + + $this->assertTrue($caught, $exception::class . ' should be catchable as HttpServerException'); + } + } + + #[Test] + public function socket_exception_error_code(): void + { + $reflection = new ReflectionClass(SocketException::class); + $exception = $reflection->newInstanceWithoutConstructor(); + + $this->assertSame('SOCKET_ERROR', $exception->getErrorCode()); + } + + #[Test] + public function parse_exception_error_code(): void + { + $reflection = new ReflectionClass(ParseException::class); + $exception = $reflection->newInstanceWithoutConstructor(); + + $this->assertSame('PARSE_ERROR', $exception->getErrorCode()); + } + + #[Test] + public function invalid_config_exception_error_code(): void + { + $reflection = new ReflectionClass(InvalidConfigException::class); + $exception = $reflection->newInstanceWithoutConstructor(); + + $this->assertSame('INVALID_CONFIG', $exception->getErrorCode()); + } + + #[Test] + public function timeout_exception_error_code(): void + { + $reflection = new ReflectionClass(TimeoutException::class); + $exception = $reflection->newInstanceWithoutConstructor(); + + $this->assertSame('TIMEOUT_ERROR', $exception->getErrorCode()); + } + + #[Test] + public function worker_pool_exception_error_code(): void + { + $reflection = new ReflectionClass(WorkerPoolException::class); + $exception = $reflection->newInstanceWithoutConstructor(); + + $this->assertSame('WORKER_POOL_ERROR', $exception->getErrorCode()); + } + + #[Test] + public function ipc_exception_error_code(): void + { + $reflection = new ReflectionClass(IPCException::class); + $exception = $reflection->newInstanceWithoutConstructor(); + + $this->assertSame('IPC_ERROR', $exception->getErrorCode()); + } + + #[Test] + public function invalid_web_socket_config_exception_error_code(): void + { + $reflection = new ReflectionClass(InvalidWebSocketConfigException::class); + $exception = $reflection->newInstanceWithoutConstructor(); + + $this->assertSame('INVALID_WEBSOCKET_CONFIG', $exception->getErrorCode()); + } + + #[Test] + public function invalid_web_socket_frame_exception_error_code(): void + { + $reflection = new ReflectionClass(InvalidWebSocketFrameException::class); + $exception = $reflection->newInstanceWithoutConstructor(); + + $this->assertSame('INVALID_WEBSOCKET_FRAME', $exception->getErrorCode()); + } +} diff --git a/tests/Unit/Handler/FileDownloadHandlerTest.php b/tests/Unit/Handler/FileDownloadHandlerTest.php index 78911ee..4b15e7f 100644 --- a/tests/Unit/Handler/FileDownloadHandlerTest.php +++ b/tests/Unit/Handler/FileDownloadHandlerTest.php @@ -102,7 +102,7 @@ public function parses_range_header(): void $range = $this->handler->parseRangeHeader('bytes=0-49', $fileSize); - $this->assertSame(['start' => 0, 'end' => 49], $range); + $this->assertSame([['start' => 0, 'end' => 49]], $range); } #[Test] @@ -112,7 +112,7 @@ public function parses_open_ended_range(): void $range = $this->handler->parseRangeHeader('bytes=50-', $fileSize); - $this->assertSame(['start' => 50, 'end' => 99], $range); + $this->assertSame([['start' => 50, 'end' => 99]], $range); } #[Test] @@ -124,4 +124,282 @@ public function returns_null_for_invalid_range_header(): void $this->assertNull($range); } + + #[Test] + public function parses_suffix_range(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=-10', $fileSize); + + $this->assertSame([['start' => 90, 'end' => 99]], $range); + } + + #[Test] + public function parses_multiple_ranges(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=0-9,20-29,50-59', $fileSize); + + $this->assertSame([ + ['start' => 0, 'end' => 9], + ['start' => 20, 'end' => 29], + ['start' => 50, 'end' => 59], + ], $range); + } + + #[Test] + public function rejects_more_than_10_ranges(): void + { + $fileSize = 1000; + $rangeHeader = 'bytes=' . implode(',', array_map(fn(int $i): string => "$i-" . ($i + 9), range(0, 100, 10))); + + $range = $this->handler->parseRangeHeader($rangeHeader, $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function rejects_overflow_start_value(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=99999999999999999999-', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function rejects_overflow_end_value(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=0-99999999999999999999', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function rejects_negative_start_value(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=-10-50', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function rejects_negative_end_value(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=0--10', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function rejects_non_numeric_value(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=abc-50', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function rejects_range_without_bytes_prefix(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('0-49', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function skips_invalid_range_in_multi_range(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=0-9,200-299,50-59', $fileSize); + + $this->assertSame([ + ['start' => 0, 'end' => 9], + ['start' => 50, 'end' => 59], + ], $range); + } + + #[Test] + public function returns_null_when_all_ranges_invalid(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=200-299,300-399', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function handles_suffix_range_larger_than_file(): void + { + $fileSize = 50; + + $range = $this->handler->parseRangeHeader('bytes=-100', $fileSize); + + $this->assertSame([['start' => 0, 'end' => 49]], $range); + } + + #[Test] + public function clamps_end_to_file_size(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=50-200', $fileSize); + + $this->assertSame([['start' => 50, 'end' => 99]], $range); + } + + #[Test] + public function rejects_empty_range_parts(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=-', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function rejects_range_with_only_start_equals_file_size(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=100-', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function rejects_range_start_greater_than_end(): void + { + $fileSize = 100; + + $range = $this->handler->parseRangeHeader('bytes=50-10', $fileSize); + + $this->assertNull($range); + } + + #[Test] + public function handles_large_valid_range_value(): void + { + $fileSize = PHP_INT_MAX; + + $range = $this->handler->parseRangeHeader('bytes=0-999999999999999999', $fileSize); + + $this->assertSame([['start' => 0, 'end' => 999999999999999999]], $range); + } + + #[Test] + public function returns_404_for_non_existent_file_in_range_download(): void + { + $response = $this->handler->downloadRange('/non/existent/file.txt', 0, 10); + + $this->assertSame(404, $response->getStatusCode()); + } + + #[Test] + public function returns_416_for_negative_start_in_range(): void + { + $response = $this->handler->downloadRange($this->tempFile, -1, 10); + + $this->assertSame(416, $response->getStatusCode()); + } + + #[Test] + public function returns_416_for_start_greater_than_end_in_range(): void + { + $response = $this->handler->downloadRange($this->tempFile, 10, 5); + + $this->assertSame(416, $response->getStatusCode()); + } + + #[Test] + public function returns_416_for_start_at_file_size(): void + { + $fileSize = filesize($this->tempFile); + + $response = $this->handler->downloadRange($this->tempFile, $fileSize, $fileSize + 10); + + $this->assertSame(416, $response->getStatusCode()); + } + + #[Test] + public function downloads_full_range_from_start(): void + { + $fileSize = filesize($this->tempFile); + + $response = $this->handler->downloadRange($this->tempFile, 0, $fileSize - 1); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame('test content for download', (string) $response->getBody()); + } + + #[Test] + public function sets_custom_filename_in_range_download(): void + { + $response = $this->handler->downloadRange($this->tempFile, 0, 4, 'custom.txt'); + + $this->assertStringContainsString('custom.txt', $response->getHeaderLine('Content-Disposition')); + } + + #[Test] + public function sets_custom_mime_type_in_range_download(): void + { + $response = $this->handler->downloadRange($this->tempFile, 0, 4, null, 'application/custom'); + + $this->assertSame('application/custom', $response->getHeaderLine('Content-Type')); + } + + #[Test] + public function detects_pdf_mime_type(): void + { + $tempPdf = tempnam(sys_get_temp_dir(), 'test_') . '.pdf'; + file_put_contents($tempPdf, '%PDF-1.4'); + + $response = $this->handler->download($tempPdf); + + unlink($tempPdf); + + $this->assertStringContainsString('application/pdf', $response->getHeaderLine('Content-Type')); + } + + #[Test] + public function detects_json_mime_type(): void + { + $tempJson = tempnam(sys_get_temp_dir(), 'test_') . '.json'; + file_put_contents($tempJson, '{}'); + + $response = $this->handler->download($tempJson); + + unlink($tempJson); + + $this->assertStringContainsString('application/json', $response->getHeaderLine('Content-Type')); + } + + #[Test] + public function defaults_to_octet_stream_for_unknown_extension(): void + { + $tempUnknown = tempnam(sys_get_temp_dir(), 'test_') . '.xyz123'; + file_put_contents($tempUnknown, chr(0) . chr(1) . chr(2) . chr(3)); + + $response = $this->handler->download($tempUnknown); + + unlink($tempUnknown); + + $this->assertStringContainsString('application/octet-stream', $response->getHeaderLine('Content-Type')); + } } diff --git a/tests/Unit/Handler/StaticFileHandlerTest.php b/tests/Unit/Handler/StaticFileHandlerTest.php index a5a0bc8..9aae152 100644 --- a/tests/Unit/Handler/StaticFileHandlerTest.php +++ b/tests/Unit/Handler/StaticFileHandlerTest.php @@ -374,6 +374,303 @@ public function lru_eviction_preserves_most_recent_files(): void $this->assertSame('content5', (string) $response5->getBody()); } + #[Test] + public function is_static_file_returns_true_for_existing_file(): void + { + $file = $this->tempDir . '/test.txt'; + file_put_contents($file, 'content'); + + $request = new ServerRequest('GET', '/test.txt'); + $this->assertTrue($this->handler->isStaticFile($request)); + } + + #[Test] + public function is_static_file_returns_false_for_root(): void + { + $request = new ServerRequest('GET', '/'); + $this->assertFalse($this->handler->isStaticFile($request)); + } + + #[Test] + public function is_static_file_returns_false_for_empty_path(): void + { + $request = new ServerRequest('GET', ''); + $this->assertFalse($this->handler->isStaticFile($request)); + } + + #[Test] + public function is_static_file_returns_false_for_non_existent(): void + { + $request = new ServerRequest('GET', '/nonexistent.txt'); + $this->assertFalse($this->handler->isStaticFile($request)); + } + + #[Test] + public function serves_file_without_cache_when_disabled(): void + { + $handler = new StaticFileHandler($this->tempDir, false, 1048576); + $file = $this->tempDir . '/nocache.txt'; + file_put_contents($file, 'no cache content'); + + $request = new ServerRequest('GET', '/nocache.txt'); + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('no cache content', (string) $response->getBody()); + + $stats = $handler->getCacheStats(); + $this->assertSame(0, $stats['entries']); + } + + #[Test] + public function returns_304_for_if_modified_since(): void + { + $file = $this->tempDir . '/modified.txt'; + file_put_contents($file, 'modified content'); + + $mtime = filemtime($file); + $ifModifiedSince = gmdate('D, d M Y H:i:s', $mtime) . ' GMT'; + + $request = (new ServerRequest('GET', '/modified.txt')) + ->withHeader('If-Modified-Since', $ifModifiedSince); + + $response = $this->handler->handle($request); + $this->assertSame(304, $response->getStatusCode()); + } + + #[Test] + public function invalidates_cache_on_file_change(): void + { + $file = $this->tempDir . '/changing.txt'; + file_put_contents($file, 'original'); + + $request = new ServerRequest('GET', '/changing.txt'); + $response1 = $this->handler->handle($request); + $this->assertSame('original', (string) $response1->getBody()); + + clearstatcache(true, $file); + file_put_contents($file, 'modified'); + touch($file, time() + 1); + clearstatcache(true, $file); + + $response2 = $this->handler->handle($request); + $this->assertSame('modified', (string) $response2->getBody()); + } + + #[Test] + public function lru_single_entry_eviction(): void + { + $handler = new StaticFileHandler($this->tempDir, true, 1024, 1); + + $file1 = $this->tempDir . '/single1.txt'; + $file2 = $this->tempDir . '/single2.txt'; + file_put_contents($file1, 'a'); + file_put_contents($file2, 'b'); + + $handler->handle(new ServerRequest('GET', '/single1.txt')); + $stats = $handler->getCacheStats(); + $this->assertSame(1, $stats['entries']); + + $handler->handle(new ServerRequest('GET', '/single2.txt')); + $stats = $handler->getCacheStats(); + $this->assertSame(1, $stats['entries']); + } + + #[Test] + public function lru_access_same_file_twice(): void + { + $handler = new StaticFileHandler($this->tempDir, true, 1048576, 3); + + $file = $this->tempDir . '/same.txt'; + file_put_contents($file, 'same content'); + + $handler->handle(new ServerRequest('GET', '/same.txt')); + $handler->handle(new ServerRequest('GET', '/same.txt')); + $handler->handle(new ServerRequest('GET', '/same.txt')); + + $stats = $handler->getCacheStats(); + $this->assertSame(1, $stats['entries']); + } + + #[Test] + public function mime_type_for_various_extensions(): void + { + $extensions = [ + 'css' => 'text/css', + 'js' => 'application/javascript', + 'json' => 'application/json', + 'png' => 'image/png', + 'svg' => 'image/svg+xml', + ]; + + foreach ($extensions as $ext => $expectedMime) { + $file = $this->tempDir . "/test.{$ext}"; + file_put_contents($file, 'content'); + + $request = new ServerRequest('GET', "/test.{$ext}"); + $response = $this->handler->handle($request); + + $this->assertSame($expectedMime, $response->getHeaderLine('Content-Type')); + } + } + + #[Test] + public function unknown_extension_returns_octet_stream(): void + { + $file = $this->tempDir . '/test.unknownext'; + file_put_contents($file, 'content'); + + $request = new ServerRequest('GET', '/test.unknownext'); + $response = $this->handler->handle($request); + + $this->assertSame('application/octet-stream', $response->getHeaderLine('Content-Type')); + } + + #[Test] + public function cache_size_exceeds_limit_returns_uncached(): void + { + $handler = new StaticFileHandler($this->tempDir, true, 100, 100); + + $file = $this->tempDir . '/big.txt'; + file_put_contents($file, str_repeat('x', 200)); + + $request = new ServerRequest('GET', '/big.txt'); + $response = $handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(str_repeat('x', 200), (string) $response->getBody()); + + $stats = $handler->getCacheStats(); + $this->assertSame(0, $stats['entries']); + } + + #[Test] + public function file_larger_than_max_cache_size_is_streamed(): void + { + $file = $this->tempDir . '/streamed.css'; + $content = str_repeat('body { margin: 0; } ', 100000); + file_put_contents($file, $content); + + $request = new ServerRequest('GET', '/streamed.css'); + $response = $this->handler->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('text/css', $response->getHeaderLine('Content-Type')); + } + + #[Test] + public function if_modified_since_future_returns_200(): void + { + $file = $this->tempDir . '/future.txt'; + file_put_contents($file, 'future content'); + + $futureTime = time() + 3600; + $ifModifiedSince = gmdate('D, d M Y H:i:s', $futureTime) . ' GMT'; + + $request = (new ServerRequest('GET', '/future.txt')) + ->withHeader('If-Modified-Since', $ifModifiedSince); + + $response = $this->handler->handle($request); + $this->assertSame(304, $response->getStatusCode()); + } + + #[Test] + public function invalidates_single_cached_file_on_change(): void + { + $handler = new StaticFileHandler($this->tempDir, true, 1048576, 1); + + $file = $this->tempDir . '/single.txt'; + file_put_contents($file, 'original'); + + $handler->handle(new ServerRequest('GET', '/single.txt')); + $stats = $handler->getCacheStats(); + $this->assertSame(1, $stats['entries']); + + clearstatcache(true, $file); + file_put_contents($file, 'modified'); + touch($file, time() + 1); + clearstatcache(true, $file); + + $handler->handle(new ServerRequest('GET', '/single.txt')); + $stats = $handler->getCacheStats(); + $this->assertSame(1, $stats['entries']); + } + + #[Test] + public function eviction_removes_correct_size_from_cache(): void + { + $handler = new StaticFileHandler($this->tempDir, true, 500, 3); + + $file1 = $this->tempDir . '/size1.txt'; + $file2 = $this->tempDir . '/size2.txt'; + file_put_contents($file1, str_repeat('a', 200)); + file_put_contents($file2, str_repeat('b', 400)); + + $handler->handle(new ServerRequest('GET', '/size1.txt')); + usleep(10000); + $handler->handle(new ServerRequest('GET', '/size2.txt')); + + $stats = $handler->getCacheStats(); + $this->assertLessThanOrEqual(500, $stats['size']); + } + + #[Test] + public function handle_nonexistent_public_path_returns_null(): void + { + $nonExistentDir = sys_get_temp_dir() . '/nonexistent_' . uniqid(); + $handler = new StaticFileHandler($nonExistentDir, true, 1048576); + + $request = new ServerRequest('GET', '/test.txt'); + $response = $handler->handle($request); + + $this->assertNull($response); + } + + #[Test] + public function handle_unreadable_file_returns_403(): void + { + if (0 === posix_getuid()) { + $this->markTestSkipped('Cannot test unreadable files as root'); + } + + $file = $this->tempDir . '/unreadable.txt'; + file_put_contents($file, 'unreadable content'); + chmod($file, 0000); + + $request = new ServerRequest('GET', '/unreadable.txt'); + $response = $this->handler->handle($request); + + $this->assertNotNull($response); + $this->assertSame(403, $response->getStatusCode()); + + chmod($file, 0644); + } + + #[Test] + public function lru_performance_with_many_files(): void + { + $handler = new StaticFileHandler($this->tempDir, true, 10485760, 100); + + for ($i = 1; $i <= 100; $i++) { + $file = $this->tempDir . "/file{$i}.txt"; + file_put_contents($file, "content{$i}"); + } + + $start = microtime(true); + + for ($round = 1; $round <= 10; $round++) { + for ($i = 1; $i <= 100; $i++) { + $handler->handle(new ServerRequest('GET', "/file{$i}.txt")); + } + } + + $elapsed = microtime(true) - $start; + + $stats = $handler->getCacheStats(); + $this->assertLessThanOrEqual(100, $stats['entries']); + $this->assertLessThan(3.0, $elapsed, 'LRU operations should complete in under 3 seconds for 1000 operations'); + } + private function removeDirectory(string $dir): void { if (!is_dir($dir)) { diff --git a/tests/Unit/Parser/HttpParserTest.php b/tests/Unit/Parser/HttpParserTest.php index e1658c5..97cb473 100644 --- a/tests/Unit/Parser/HttpParserTest.php +++ b/tests/Unit/Parser/HttpParserTest.php @@ -276,4 +276,43 @@ public function case_insensitive_duplicate_detection(): void $headerBlock = "content-length: 100\r\nCONTENT-LENGTH: 200\r\n"; $this->parser->parseHeaders($headerBlock); } + + #[Test] + public function default_header_cache_limit_is_100(): void + { + HttpParser::clearHeaderCache(); + $parser = new HttpParser(); + + $headerBlock = "host: example.com\r\n"; + $headers = $parser->parseHeaders($headerBlock); + + $this->assertArrayHasKey('Host', $headers); + } + + #[Test] + public function custom_header_cache_limit_works(): void + { + HttpParser::clearHeaderCache(); + $parser = new HttpParser(headerCacheLimit: 5); + + for ($i = 0; $i < 10; $i++) { + $headerBlock = "x-custom-$i: value$i\r\n"; + $result = $parser->parseHeaders($headerBlock); + $this->assertArrayHasKey("X-Custom-$i", $result); + } + } + + #[Test] + public function header_cache_respects_limit(): void + { + HttpParser::clearHeaderCache(); + $parser = new HttpParser(headerCacheLimit: 2); + + $parser->parseHeaders("x-header-a: 1\r\n"); + $parser->parseHeaders("x-header-b: 2\r\n"); + $parser->parseHeaders("x-header-c: 3\r\n"); + $headers = $parser->parseHeaders("x-header-a: 4\r\n"); + + $this->assertArrayHasKey('X-Header-A', $headers); + } } diff --git a/tests/Unit/Parser/RequestParserTest.php b/tests/Unit/Parser/RequestParserTest.php index 369664b..0938e83 100644 --- a/tests/Unit/Parser/RequestParserTest.php +++ b/tests/Unit/Parser/RequestParserTest.php @@ -158,4 +158,207 @@ public function handles_request_without_host_header(): void $this->assertSame('GET', $request->getMethod()); $this->assertSame('/', $request->getUri()->getPath()); } + + #[Test] + public function parses_cookies_with_urlencoded_value(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: token=hello%40world\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertSame('hello@world', $cookies['token']); + } + + #[Test] + public function rejects_cookie_with_invalid_name_containing_separator(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session;id=abc123\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('session;id', $cookies); + } + + #[Test] + public function rejects_cookie_with_invalid_name_containing_parentheses(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: (session)=abc123\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('(session)', $cookies); + } + + #[Test] + public function rejects_cookie_with_invalid_name_containing_comma(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session,id=abc123\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('session,id', $cookies); + } + + #[Test] + public function rejects_cookie_with_invalid_name_containing_at(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session@id=abc123\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('session@id', $cookies); + } + + #[Test] + public function rejects_cookie_with_empty_name(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: =value\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('', $cookies); + } + + #[Test] + public function accepts_cookie_name_with_special_rfc_chars(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session-id_test.user=value123\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertSame('value123', $cookies['session-id_test.user']); + } + + #[Test] + public function rejects_cookie_value_with_null_byte(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session=abc%00def\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('session', $cookies); + } + + #[Test] + public function rejects_cookie_value_with_carriage_return(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session=abc%0Ddef\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('session', $cookies); + } + + #[Test] + public function rejects_cookie_value_with_newline(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session=abc%0Adef\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('session', $cookies); + } + + #[Test] + public function rejects_cookie_value_with_tab(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session=abc\tdef\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('session', $cookies); + } + + #[Test] + public function rejects_cookie_value_exceeding_max_length(): void + { + $longValue = str_repeat('a', 4097); + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session={$longValue}\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertArrayNotHasKey('session', $cookies); + } + + #[Test] + public function accepts_cookie_value_at_max_length(): void + { + $maxLengthValue = str_repeat('a', 4096); + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session={$maxLengthValue}\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertSame($maxLengthValue, $cookies['session']); + } + + #[Test] + public function parses_mix_of_valid_and_invalid_cookies(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: valid1=abc; invalid=%00bad; valid2=xyz; valid3=123\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertSame('abc', $cookies['valid1']); + $this->assertArrayNotHasKey('invalid', $cookies); + $this->assertSame('xyz', $cookies['valid2']); + $this->assertSame('123', $cookies['valid3']); + } + + #[Test] + public function accepts_cookie_value_with_space(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session=hello world\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertSame('hello world', $cookies['session']); + } + + #[Test] + public function accepts_cookie_value_with_special_chars(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: token=abc!def#xyz\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertSame('abc!def#xyz', $cookies['token']); + } + + #[Test] + public function handles_empty_cookie_header(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: \r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertSame([], $cookies); + } + + #[Test] + public function accepts_cookie_with_empty_value(): void + { + $rawRequest = "GET / HTTP/1.1\r\nHost: localhost\r\nCookie: session=\r\n\r\n"; + + $request = $this->parser->parse($rawRequest, '127.0.0.1', 8080); + + $cookies = $request->getCookieParams(); + $this->assertSame('', $cookies['session']); + } } diff --git a/tests/Unit/RateLimit/RateLimiterTest.php b/tests/Unit/RateLimit/RateLimiterTest.php index d2dcc7e..ab2a747 100644 --- a/tests/Unit/RateLimit/RateLimiterTest.php +++ b/tests/Unit/RateLimit/RateLimiterTest.php @@ -203,4 +203,87 @@ public function cleanup_preserves_active_requests(): void $this->assertSame(2, $limiter->getActiveIdentifiersCount()); } + + #[Test] + public function auto_cleanup_triggers_after_interval(): void + { + $limiter = new RateLimiter(100, 1, 10); + + for ($i = 0; $i < 5; $i++) { + $limiter->isAllowed('client' . $i); + } + + $this->assertSame(5, $limiter->getActiveIdentifiersCount()); + + sleep(2); + + for ($i = 5; $i < 15; $i++) { + $limiter->isAllowed('client' . $i); + } + + $this->assertSame(10, $limiter->getActiveIdentifiersCount()); + } + + #[Test] + public function auto_cleanup_with_custom_interval(): void + { + $limiter = new RateLimiter(100, 1, 5); + + for ($i = 0; $i < 5; $i++) { + $limiter->isAllowed('client' . $i); + } + + $this->assertSame(5, $limiter->getActiveIdentifiersCount()); + + sleep(2); + + for ($i = 5; $i < 10; $i++) { + $limiter->isAllowed('client' . $i); + } + + $this->assertSame(5, $limiter->getActiveIdentifiersCount()); + } + + #[Test] + public function memory_usage_does_not_grow_infinitely(): void + { + $limiter = new RateLimiter(100, 1, 50); + + for ($i = 0; $i < 100; $i++) { + $limiter->isAllowed('client' . $i); + } + + $this->assertSame(100, $limiter->getActiveIdentifiersCount()); + + sleep(2); + + for ($i = 100; $i < 150; $i++) { + $limiter->isAllowed('client' . $i); + } + + $this->assertSame(50, $limiter->getActiveIdentifiersCount()); + } + + #[Test] + public function get_config_includes_cleanup_interval(): void + { + $limiter = new RateLimiter(100, 30, 50); + + $config = $limiter->getConfig(); + + $this->assertSame(100, $config['max_requests']); + $this->assertSame(30, $config['window_seconds']); + $this->assertSame(50, $config['cleanup_interval']); + } + + #[Test] + public function default_cleanup_interval_is_100(): void + { + $limiter = new RateLimiter(100, 60); + + $config = $limiter->getConfig(); + + $this->assertSame(100, $config['cleanup_interval']); + } + } diff --git a/tests/Unit/ServerFiberTest.php b/tests/Unit/ServerFiberTest.php new file mode 100644 index 0000000..2abceb9 --- /dev/null +++ b/tests/Unit/ServerFiberTest.php @@ -0,0 +1,242 @@ +server = new Server($config); + } + + #[Test] + public function unregister_fiber_removes_registered_fiber(): void + { + $fiber = new Fiber(function (): void { + Fiber::suspend(); + }); + + $fiber->start(); + $this->server->registerFiber($fiber); + + $result = $this->server->unregisterFiber($fiber); + + $this->assertTrue($result); + } + + #[Test] + public function unregister_fiber_returns_false_for_non_registered_fiber(): void + { + $fiber = new Fiber(function (): void { + Fiber::suspend(); + }); + + $fiber->start(); + + $result = $this->server->unregisterFiber($fiber); + + $this->assertFalse($result); + } + + #[Test] + public function unregister_fiber_returns_false_after_second_unregister(): void + { + $fiber = new Fiber(function (): void { + Fiber::suspend(); + }); + + $fiber->start(); + $this->server->registerFiber($fiber); + + $firstResult = $this->server->unregisterFiber($fiber); + $secondResult = $this->server->unregisterFiber($fiber); + + $this->assertTrue($firstResult); + $this->assertFalse($secondResult); + } + + #[Test] + public function terminated_fibers_are_cleaned_up_in_has_request(): void + { + $this->server->start(); + + $terminated = new Fiber(function (): void { + // Terminates immediately + }); + + $suspended = new Fiber(function (): void { + Fiber::suspend(); + }); + + $terminated->start(); + $suspended->start(); + + $this->server->registerFiber($terminated); + $this->server->registerFiber($suspended); + + $this->assertTrue($terminated->isTerminated()); + $this->assertTrue($suspended->isSuspended()); + + $this->server->hasRequest(); + + // Suspended fiber should still be registered (can be unregistered) + $unregisterResult = $this->server->unregisterFiber($suspended); + $this->assertTrue($unregisterResult); + + // Terminated fiber should be cleaned up, so unregister returns false + $terminatedUnregisterResult = $this->server->unregisterFiber($terminated); + $this->assertFalse($terminatedUnregisterResult); + + $this->server->stop(); + } + + #[Test] + public function multiple_terminated_fibers_are_cleaned_up(): void + { + $this->server->start(); + + $fiber1 = new Fiber(function (): void { + // Terminates immediately + }); + + $fiber2 = new Fiber(function (): void { + // Terminates immediately + }); + + $fiber3 = new Fiber(function (): void { + Fiber::suspend(); + }); + + $fiber1->start(); + $fiber2->start(); + $fiber3->start(); + + $this->server->registerFiber($fiber1); + $this->server->registerFiber($fiber2); + $this->server->registerFiber($fiber3); + + $this->server->hasRequest(); + + // Only fiber3 should remain registered + $this->assertTrue($this->server->unregisterFiber($fiber3)); + $this->assertFalse($this->server->unregisterFiber($fiber1)); + $this->assertFalse($this->server->unregisterFiber($fiber2)); + + $this->server->stop(); + } + + #[Test] + public function reset_clears_all_fibers(): void + { + $this->server->start(); + + $fiber1 = new Fiber(function (): void { + Fiber::suspend(); + }); + + $fiber2 = new Fiber(function (): void { + Fiber::suspend(); + }); + + $fiber1->start(); + $fiber2->start(); + + $this->server->registerFiber($fiber1); + $this->server->registerFiber($fiber2); + + $this->server->reset(); + + // After reset, all fibers should be cleared + $this->assertFalse($this->server->unregisterFiber($fiber1)); + $this->assertFalse($this->server->unregisterFiber($fiber2)); + } + + #[Test] + public function fiber_array_is_reindexed_after_cleanup(): void + { + $this->server->start(); + + $fiber1 = new Fiber(function (): void { + Fiber::suspend(); + }); + + $fiber2 = new Fiber(function (): void { + // Terminates immediately + }); + + $fiber3 = new Fiber(function (): void { + Fiber::suspend(); + }); + + $fiber1->start(); + $fiber2->start(); + $fiber3->start(); + + $this->server->registerFiber($fiber1); + $this->server->registerFiber($fiber2); + $this->server->registerFiber($fiber3); + + $this->server->hasRequest(); + + // After cleanup of terminated fiber2, remaining fibers should still be unregistrable + $this->assertTrue($this->server->unregisterFiber($fiber1)); + $this->assertFalse($this->server->unregisterFiber($fiber2)); // Already cleaned up + $this->assertTrue($this->server->unregisterFiber($fiber3)); + + $this->server->stop(); + } + + #[Test] + public function suspended_fibers_continue_to_be_resumed_after_cleanup(): void + { + $this->server->start(); + + $resumeCount = 0; + + $terminated = new Fiber(function (): void { + // Terminates immediately + }); + + $suspended = new Fiber(function () use (&$resumeCount): void { + while (true) { + $resumeCount++; + Fiber::suspend(); + } + }); + + $terminated->start(); + $suspended->start(); + + $this->server->registerFiber($terminated); + $this->server->registerFiber($suspended); + + $this->assertSame(1, $resumeCount); + + $this->server->hasRequest(); + $this->assertSame(2, $resumeCount); + + $this->server->hasRequest(); + $this->assertSame(3, $resumeCount); + + $this->server->stop(); + } +} diff --git a/tests/Unit/Socket/StreamSocketResourceTest.php b/tests/Unit/Socket/StreamSocketResourceTest.php index c679f51..7eed954 100644 --- a/tests/Unit/Socket/StreamSocketResourceTest.php +++ b/tests/Unit/Socket/StreamSocketResourceTest.php @@ -8,7 +8,9 @@ use Duyler\HttpServer\Socket\StreamSocketResource; use InvalidArgumentException; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Socket; class StreamSocketResourceTest extends TestCase @@ -136,4 +138,161 @@ public function get_internal_resource_returns_socket(): void $resource->close(); } + + #[Test] + public function close_is_idempotent(): void + { + $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $resource = new StreamSocketResource($socket); + + $resource->close(); + $resource->close(); + $resource->close(); + + $this->assertFalse($resource->isValid()); + } + + #[Test] + public function close_with_custom_logger(): void + { + $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + /** @var LoggerInterface&MockObject $logger */ + $logger = $this->createMock(LoggerInterface::class); + + $resource = new StreamSocketResource($socket, $logger); + + $resource->close(); + + $this->assertFalse($resource->isValid()); + } + + #[Test] + public function creates_from_stream_resource(): void + { + $stream = fopen('php://memory', 'r+'); + $this->assertIsResource($stream); + + $resource = new StreamSocketResource($stream); + + $this->assertTrue($resource->isValid()); + + $resource->close(); + } + + #[Test] + public function is_valid_returns_false_after_stream_close(): void + { + $stream = fopen('php://memory', 'r+'); + $resource = new StreamSocketResource($stream); + + $this->assertTrue($resource->isValid()); + + $resource->close(); + + $this->assertFalse($resource->isValid()); + } + + #[Test] + public function get_internal_resource_returns_null_after_close(): void + { + $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $resource = new StreamSocketResource($socket); + + $resource->close(); + + $this->assertNull($resource->getInternalResource()); + } + + #[Test] + public function set_blocking_on_stream_resource(): void + { + $stream = fopen('php://memory', 'r+'); + $resource = new StreamSocketResource($stream); + + $resource->setBlocking(false); + $this->assertTrue($resource->isValid()); + + $resource->setBlocking(true); + $this->assertTrue($resource->isValid()); + + $resource->close(); + } + + #[Test] + public function write_to_stream_resource(): void + { + $stream = fopen('php://memory', 'r+'); + $resource = new StreamSocketResource($stream); + + $written = $resource->write('test data'); + + $this->assertGreaterThan(0, $written); + + $resource->close(); + } + + #[Test] + public function read_from_stream_resource(): void + { + $stream = fopen('php://memory', 'r+'); + fwrite($stream, 'test data'); + rewind($stream); + + $resource = new StreamSocketResource($stream); + + $data = $resource->read(4); + + $this->assertSame('test', $data); + + $resource->close(); + } + #[Test] + public function read_returns_false_on_negative_length(): void + { + $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $resource = new StreamSocketResource($socket); + + $result = $resource->read(-1); + + $this->assertFalse($result); + + $resource->close(); + } + #[Test] + public function read_from_socket_object(): void + { + $sockets = []; + socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets); + [$server, $client] = $sockets; + + socket_write($server, 'test data'); + + $resource = new StreamSocketResource($client); + + $data = $resource->read(4); + + $this->assertSame('test', $data); + + $resource->close(); + socket_close($server); + } + #[Test] + public function write_to_socket_object(): void + { + $sockets = []; + socket_create_pair(AF_UNIX, SOCK_STREAM, 0, $sockets); + [$server, $client] = $sockets; + + $resource = new StreamSocketResource($client); + + $written = $resource->write('test data'); + + $this->assertGreaterThan(0, $written); + + $resource->close(); + socket_close($server); + } + + + } diff --git a/tests/Unit/Socket/StreamSocketTest.php b/tests/Unit/Socket/StreamSocketTest.php index 9aecf56..1f82dd9 100644 --- a/tests/Unit/Socket/StreamSocketTest.php +++ b/tests/Unit/Socket/StreamSocketTest.php @@ -66,7 +66,6 @@ private function getSocketPort(StreamSocket $socket): int { $reflection = new ReflectionClass($socket); $property = $reflection->getProperty('socket'); - $property->setAccessible(true); $socketResource = $property->getValue($socket); if ($socketResource instanceof Socket) { diff --git a/tests/Unit/WebSocket/HandshakeTest.php b/tests/Unit/WebSocket/HandshakeTest.php index 0445836..20f06ae 100644 --- a/tests/Unit/WebSocket/HandshakeTest.php +++ b/tests/Unit/WebSocket/HandshakeTest.php @@ -232,4 +232,38 @@ public function rejects_missing_origin_when_validation_enabled(): void $this->assertFalse(Handshake::validateOrigin($request, $config)); } + + + #[Test] + public function detects_insecure_config_when_validation_disabled_with_wildcard(): void + { + $config = new WebSocketConfig( + validateOrigin: false, + allowedOrigins: ['*'], + ); + + $this->assertTrue(Handshake::isInsecureConfig($config)); + } + + #[Test] + public function detects_secure_config_when_validation_enabled(): void + { + $config = new WebSocketConfig( + validateOrigin: true, + allowedOrigins: ['*'], + ); + + $this->assertFalse(Handshake::isInsecureConfig($config)); + } + + #[Test] + public function detects_secure_config_when_validation_disabled_without_wildcard(): void + { + $config = new WebSocketConfig( + validateOrigin: false, + allowedOrigins: ['https://example.com'], + ); + + $this->assertFalse(Handshake::isInsecureConfig($config)); + } } diff --git a/tests/Unit/WebSocket/MessageTest.php b/tests/Unit/WebSocket/MessageTest.php index a05c732..43b1d47 100644 --- a/tests/Unit/WebSocket/MessageTest.php +++ b/tests/Unit/WebSocket/MessageTest.php @@ -8,6 +8,7 @@ use Duyler\HttpServer\WebSocket\Message; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; class MessageTest extends TestCase { @@ -121,4 +122,65 @@ public function parses_nested_json(): void $this->assertIsArray($parsed); $this->assertSame('nested value', $parsed['data']['nested']['deeply']); } + + #[Test] + public function logs_debug_on_invalid_json(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('debug') + ->with( + 'Failed to parse WebSocket message as JSON', + $this->callback(fn(array $context): bool => isset($context['error']) + && isset($context['payload_length']) + && isset($context['opcode']) + && $context['opcode'] === 'TEXT' + && $context['payload_length'] === 14), + ); + + $message = new Message('not valid json', Opcode::TEXT, $logger); + $message->getJson(); + } + + #[Test] + public function logs_debug_on_non_array_json(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('debug') + ->with( + 'WebSocket JSON message is not an array', + $this->callback(fn(array $context): bool => isset($context['type']) && $context['type'] === 'string'), + ); + + $message = new Message('"just a string"', Opcode::TEXT, $logger); + $message->getJson(); + } + + #[Test] + public function does_not_log_on_valid_json(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->never()) + ->method('debug'); + + $jsonData = json_encode(['type' => 'test']); + $message = new Message($jsonData, Opcode::TEXT, $logger); + $message->getJson(); + } + + #[Test] + public function does_not_log_on_binary_message(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->never()) + ->method('debug'); + + $message = new Message('not valid json', Opcode::BINARY, $logger); + $message->getJson(); + } } diff --git a/tests/Unit/WebSocket/WebSocketConfigTest.php b/tests/Unit/WebSocket/WebSocketConfigTest.php index b55e93a..0c1a88c 100644 --- a/tests/Unit/WebSocket/WebSocketConfigTest.php +++ b/tests/Unit/WebSocket/WebSocketConfigTest.php @@ -24,7 +24,7 @@ public function creates_with_default_values(): void $this->assertSame(5, $config->handshakeTimeout); $this->assertSame(5, $config->closeTimeout); $this->assertSame(['*'], $config->allowedOrigins); - $this->assertFalse($config->validateOrigin); + $this->assertTrue($config->validateOrigin); $this->assertTrue($config->requireMasking); $this->assertTrue($config->autoFragmentation); $this->assertSame(8192, $config->writeBufferSize); diff --git a/tests/Unit/WorkerPool/Balancer/LeastConnectionsBalancerTest.php b/tests/Unit/WorkerPool/Balancer/LeastConnectionsBalancerTest.php index bd2beaf..68064ef 100644 --- a/tests/Unit/WorkerPool/Balancer/LeastConnectionsBalancerTest.php +++ b/tests/Unit/WorkerPool/Balancer/LeastConnectionsBalancerTest.php @@ -236,4 +236,59 @@ public function selects_new_worker_after_connections_change(): void $this->assertSame(3, $result2, 'Should now select worker 3'); } + + #[Test] + public function removes_worker_from_connections(): void + { + $this->balancer->selectWorker([1 => 10, 2 => 5, 3 => 7]); + + $this->balancer->onWorkerRemoved(2); + + $connections = $this->balancer->getConnections(); + + $this->assertArrayNotHasKey(2, $connections); + $this->assertSame(10, $connections[1]); + $this->assertSame(7, $connections[3]); + } + + #[Test] + public function handles_removal_of_non_existent_worker(): void + { + $this->balancer->selectWorker([1 => 5, 2 => 3]); + + $this->balancer->onWorkerRemoved(999); + + $connections = $this->balancer->getConnections(); + + $this->assertCount(2, $connections); + $this->assertSame(5, $connections[1]); + $this->assertSame(3, $connections[2]); + } + + #[Test] + public function selects_correctly_after_worker_removal(): void + { + $this->balancer->selectWorker([1 => 10, 2 => 5, 3 => 7]); + + $this->balancer->onWorkerRemoved(2); + + $connections = $this->balancer->getConnections(); + $result = $this->balancer->selectWorker($connections); + + $this->assertSame(3, $result, 'Should select worker 3 with least connections after worker 2 removal'); + } + + #[Test] + public function handles_removal_of_all_workers(): void + { + $this->balancer->selectWorker([1 => 5, 2 => 3]); + + $this->balancer->onWorkerRemoved(1); + $this->balancer->onWorkerRemoved(2); + + $connections = $this->balancer->getConnections(); + + $this->assertEmpty($connections); + } + } diff --git a/tests/Unit/WorkerPool/Balancer/RoundRobinBalancerTest.php b/tests/Unit/WorkerPool/Balancer/RoundRobinBalancerTest.php index 7490d95..603f08f 100644 --- a/tests/Unit/WorkerPool/Balancer/RoundRobinBalancerTest.php +++ b/tests/Unit/WorkerPool/Balancer/RoundRobinBalancerTest.php @@ -185,4 +185,89 @@ public function handles_dynamic_worker_list_changes(): void $this->assertSame(1, $result, 'Should restart from beginning with new worker list'); } + + #[Test] + public function handles_worker_removal_before_current_index(): void + { + $connections = [1 => 0, 2 => 0, 3 => 0, 4 => 0]; + + $this->balancer->selectWorker($connections); + $this->balancer->selectWorker($connections); + $this->balancer->selectWorker($connections); + + $this->balancer->onWorkerRemoved(2); + + $updatedConnections = [1 => 0, 3 => 0, 4 => 0]; + $result = $this->balancer->selectWorker($updatedConnections); + + $this->assertSame(4, $result, 'Should select worker 4 after worker 2 removal'); + } + + #[Test] + public function handles_worker_removal_after_current_index(): void + { + $connections = [1 => 0, 2 => 0, 3 => 0, 4 => 0]; + + $this->balancer->selectWorker($connections); + $this->balancer->selectWorker($connections); + + $this->balancer->onWorkerRemoved(4); + + $updatedConnections = [1 => 0, 2 => 0, 3 => 0]; + $result = $this->balancer->selectWorker($updatedConnections); + + $this->assertSame(3, $result, 'Should select worker 3 after worker 4 removal'); + } + + #[Test] + public function handles_worker_removal_at_current_index(): void + { + $connections = [1 => 0, 2 => 0, 3 => 0]; + + $this->balancer->selectWorker($connections); + $this->balancer->selectWorker($connections); + + $this->balancer->onWorkerRemoved(2); + + $updatedConnections = [1 => 0, 3 => 0]; + $result = $this->balancer->selectWorker($updatedConnections); + + $this->assertSame(3, $result, 'Should select worker 3 after worker 2 removal at current index'); + } + + #[Test] + public function handles_removal_of_non_existent_worker(): void + { + $connections = [1 => 0, 2 => 0, 3 => 0]; + + $this->balancer->selectWorker($connections); + $this->balancer->selectWorker($connections); + + $this->balancer->onWorkerRemoved(999); + + $result = $this->balancer->selectWorker($connections); + + $this->assertSame(3, $result, 'Should continue normally after non-existent worker removal'); + } + + #[Test] + public function continues_rotation_after_multiple_removals(): void + { + $connections = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0]; + + $this->balancer->selectWorker($connections); + $this->balancer->selectWorker($connections); + $this->balancer->selectWorker($connections); + + $this->balancer->onWorkerRemoved(1); + $this->balancer->onWorkerRemoved(3); + + $updatedConnections = [2 => 0, 4 => 0, 5 => 0]; + $result1 = $this->balancer->selectWorker($updatedConnections); + $result2 = $this->balancer->selectWorker($updatedConnections); + + $this->assertSame(5, $result1); + $this->assertContains($result2, [2, 4, 5]); + } + } diff --git a/tests/Unit/WorkerPool/Config/WorkerPoolConfigTest.php b/tests/Unit/WorkerPool/Config/WorkerPoolConfigTest.php new file mode 100644 index 0000000..3ddb38e --- /dev/null +++ b/tests/Unit/WorkerPool/Config/WorkerPoolConfigTest.php @@ -0,0 +1,72 @@ +assertSame(1048576, $config->maxIpcMessageSize); + } + + #[Test] + public function custom_max_ipc_message_size(): void + { + $serverConfig = new ServerConfig(); + $config = new WorkerPoolConfig( + serverConfig: $serverConfig, + maxIpcMessageSize: 2097152, + ); + + $this->assertSame(2097152, $config->maxIpcMessageSize); + } + + #[Test] + public function rejects_max_ipc_message_size_below_minimum(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Max IPC message size must be at least 1024 bytes'); + + $serverConfig = new ServerConfig(); + new WorkerPoolConfig( + serverConfig: $serverConfig, + maxIpcMessageSize: 1023, + ); + } + + #[Test] + public function accepts_minimum_max_ipc_message_size(): void + { + $serverConfig = new ServerConfig(); + $config = new WorkerPoolConfig( + serverConfig: $serverConfig, + maxIpcMessageSize: 1024, + ); + + $this->assertSame(1024, $config->maxIpcMessageSize); + } + + #[Test] + public function accepts_large_max_ipc_message_size(): void + { + $serverConfig = new ServerConfig(); + $config = new WorkerPoolConfig( + serverConfig: $serverConfig, + maxIpcMessageSize: 10485760, + ); + + $this->assertSame(10485760, $config->maxIpcMessageSize); + } +} diff --git a/tests/Unit/WorkerPool/IPC/FdPasserTest.php b/tests/Unit/WorkerPool/IPC/FdPasserTest.php index bb418ec..2bbf962 100644 --- a/tests/Unit/WorkerPool/IPC/FdPasserTest.php +++ b/tests/Unit/WorkerPool/IPC/FdPasserTest.php @@ -9,6 +9,7 @@ use Exception; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use Socket; class FdPasserTest extends TestCase @@ -135,4 +136,13 @@ public function sends_fd_with_empty_metadata(): void socket_close($socket1); socket_close($socket2); } + + #[Test] + public function accepts_logger_via_constructor(): void + { + $logger = $this->createMock(LoggerInterface::class); + $passer = new FdPasser($logger); + + $this->assertIsBool($passer->isSupported()); + } } diff --git a/tests/Unit/WorkerPool/IPC/MessageTest.php b/tests/Unit/WorkerPool/IPC/MessageTest.php index d81d738..b9152e5 100644 --- a/tests/Unit/WorkerPool/IPC/MessageTest.php +++ b/tests/Unit/WorkerPool/IPC/MessageTest.php @@ -9,6 +9,7 @@ use InvalidArgumentException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; use ValueError; class MessageTest extends TestCase @@ -180,4 +181,67 @@ public function serialization_roundtrip_preserves_data(): void $this->assertSame($original->data, $restored->data); $this->assertSame($original->timestamp, $restored->timestamp); } + + #[Test] + public function logs_warning_on_invalid_json(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('warning') + ->with( + 'Failed to unserialize IPC message: JSON parse error', + $this->callback(fn(array $context): bool => isset($context['error']) + && isset($context['data_length']) + && $context['data_length'] === 12), + ); + + $this->expectException(InvalidArgumentException::class); + Message::unserialize('invalid json', $logger); + } + + #[Test] + public function logs_warning_on_non_array_json(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('warning') + ->with( + 'Failed to unserialize IPC message: decoded data is not an array', + $this->callback(fn(array $context): bool => isset($context['type']) && $context['type'] === 'string'), + ); + + $this->expectException(InvalidArgumentException::class); + Message::unserialize('"just a string"', $logger); + } + + #[Test] + public function logs_warning_on_missing_type(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->once()) + ->method('warning') + ->with('Failed to unserialize IPC message: missing type field'); + + $this->expectException(InvalidArgumentException::class); + Message::unserialize(json_encode(['data' => []]), $logger); + } + + #[Test] + public function does_not_log_on_valid_unserialize(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger + ->expects($this->never()) + ->method('warning'); + + $json = json_encode([ + 'type' => 'shutdown', + 'data' => [], + ]); + + Message::unserialize($json, $logger); + } }