commit 070fad058fe06f503d3df6887c1b3bb69a67b2a2 Author: EchoNoch Date: Thu May 28 20:19:28 2026 +0800 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3e8ecc3 --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +[config] +APP_ENV=dev +# 应用运行环境;可选值通常为 dev、test、prod,prod 下应启用更严格的认证与日志策略。 +LOG_LEVEL=debug +# 日志级别;建议值为 debug、info、warning、error,当前 MVP 预留该配置用于后续日志工厂。 +AUDIT_LOG=runtime/audit.log +# 审计日志文件路径;建议使用 runtime/*.log,MVP 会按 JSON Lines 格式追加写入。 + +[client-agent] +NODE_ID=client-01 +# 当前 Agent 的节点 ID;必须存在于 config/nodes.php,例如 client-01。 +NODE_TYPE=client +# 当前 Agent 的节点类型;当前 MVP 使用 client,需与 config/nodes.php 中的 node_type 一致。 +NODE_TOKEN=CHANGE_ME +# 当前 Agent 连接 POP Server 时使用的节点密钥;必须与 config/nodes.php 中对应节点的 token 一致。 +NODE_ZONE=default +# 当前 Agent 所在逻辑区域;可按部署场景填写,例如 local、corp、restricted-a。 +POP_SERVER_ADDRESS=tcp://127.0.0.1:9001 +# Agent 出站连接 POP Server 的地址;格式为 tcp://host:port,例如 tcp://10.1.0.2:9001。 +AGENT_TRANSPORT_PROTOCOL=tcp +# 当前 Agent 到 POP Server 使用的传输协议;可选值 tcp、udp、kcp;必须被 POP_ALLOWED_AGENT_TRANSPORTS 允许,当前可运行值为 tcp。 +CLIENT_AGENT_AUTH_TOKEN=dev-token +# Client Agent 为 SOCKS5/HTTP 代理入口生成 OPEN 帧时使用的客户端认证 token;当前 MVP 默认 dev-token。 +CLIENT_AGENT_USER_ID=admin +# Client Agent 为 SOCKS5/HTTP 代理入口生成 OPEN 帧时使用的默认用户 ID;需能匹配 config/policies.php。 +CLIENT_AGENT_SOCKS5_ENABLED=true +# 是否启用 SOCKS5 本地入口;可选 true/false,适合只能配置 SOCKS5 代理的应用。 +CLIENT_AGENT_SOCKS5_LISTEN_IP=127.0.0.1 +# SOCKS5 本地入口监听 IP;默认 127.0.0.1 仅允许本机访问,如需局域网访问可改为 0.0.0.0。 +CLIENT_AGENT_SOCKS5_LISTEN_PORT=1080 +# SOCKS5 本地入口监听端口;常见值 1080。 +CLIENT_AGENT_SOCKS5_UDP_LISTEN_IP=127.0.0.1 +# SOCKS5 UDP ASSOCIATE 本地 UDP relay 监听 IP;默认 127.0.0.1。 +CLIENT_AGENT_SOCKS5_UDP_LISTEN_PORT=1081 +# SOCKS5 UDP ASSOCIATE 本地 UDP relay 监听端口;应用发起 UDP ASSOCIATE 后会收到该端口。 +CLIENT_AGENT_SOCKS5_UDP_ADVERTISE_IP=127.0.0.1 +# SOCKS5 UDP ASSOCIATE 返回给应用的 UDP relay IP;本机使用 127.0.0.1,局域网代理可改为 Client Agent 可达地址。 +CLIENT_AGENT_SOCKS5_AUTH_MODE=no-auth +# SOCKS5 认证模式;可选 no-auth 或 userpass,userpass 使用 RFC1929 用户名/密码认证。 +CLIENT_AGENT_SOCKS5_USERNAME= +# SOCKS5 用户名;仅当 CLIENT_AGENT_SOCKS5_AUTH_MODE=userpass 时使用。 +CLIENT_AGENT_SOCKS5_PASSWORD= +# SOCKS5 密码;仅当 CLIENT_AGENT_SOCKS5_AUTH_MODE=userpass 时使用。 +CLIENT_AGENT_HTTP_PROXY_ENABLED=false +# 是否启用 HTTP 代理本地入口;可选 true/false,支持 HTTP CONNECT 和普通 HTTP 绝对 URL 请求。 +CLIENT_AGENT_HTTP_PROXY_LISTEN_IP=127.0.0.1 +# HTTP 代理本地入口监听 IP;默认 127.0.0.1 仅允许本机访问。 +CLIENT_AGENT_HTTP_PROXY_LISTEN_PORT=8080 +# HTTP 代理本地入口监听端口;常见值 8080、7890。 +CLIENT_AGENT_RAW_JSON_ENABLED=false +# 是否启用 raw-json 调试入口;可选 true/false,启用后客户端需先发送一行 JSON 请求。 +CLIENT_AGENT_RAW_JSON_LISTEN_IP=127.0.0.1 +# raw-json 调试入口监听 IP;默认 127.0.0.1。 +CLIENT_AGENT_RAW_JSON_LISTEN_PORT=9000 +# raw-json 调试入口监听端口;常见值 9000。 + +[pop-server] +POP_AGENT_LISTEN=0.0.0.0:9001 +# POP Server 监听 Agent 长连接的地址;格式为 host:port,例如 0.0.0.0:9001 或 127.0.0.1:9001。 +POP_ALLOWED_AGENT_TRANSPORTS=tcp +# POP Server 允许 Client Agent 使用的传输协议;逗号分隔,可选值 tcp、udp、kcp;当前已实现 tcp,udp/kcp 为预留实现。 diff --git a/bin/client-agent.php b/bin/client-agent.php new file mode 100755 index 0000000..71121b3 --- /dev/null +++ b/bin/client-agent.php @@ -0,0 +1,65 @@ +#!/usr/bin/env php +boot($name); +}; + +if (Env::bool('CLIENT_AGENT_SOCKS5_ENABLED', true)) { + $bootAgent( + 'socks5', + Env::get('CLIENT_AGENT_SOCKS5_LISTEN_IP', '127.0.0.1') . ':' . Env::get('CLIENT_AGENT_SOCKS5_LISTEN_PORT', '1080'), + 'laylink-client-agent-socks5', + ); +} + +if (Env::bool('CLIENT_AGENT_HTTP_PROXY_ENABLED', false)) { + $bootAgent( + 'http-proxy', + Env::get('CLIENT_AGENT_HTTP_PROXY_LISTEN_IP', '127.0.0.1') . ':' . Env::get('CLIENT_AGENT_HTTP_PROXY_LISTEN_PORT', '8080'), + 'laylink-client-agent-http-proxy', + ); +} + +if (Env::bool('CLIENT_AGENT_RAW_JSON_ENABLED', false)) { + $bootAgent( + 'raw-json', + Env::get('CLIENT_AGENT_RAW_JSON_LISTEN_IP', '127.0.0.1') . ':' . Env::get('CLIENT_AGENT_RAW_JSON_LISTEN_PORT', '9000'), + 'laylink-client-agent-raw-json', + ); +} + +Worker::runAll(); diff --git a/bin/pop-server.php b/bin/pop-server.php new file mode 100755 index 0000000..5a61f67 --- /dev/null +++ b/bin/pop-server.php @@ -0,0 +1,25 @@ +#!/usr/bin/env php +boot(); + +Worker::runAll(); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9562dda --- /dev/null +++ b/composer.json @@ -0,0 +1,10 @@ +{ + "autoload": { + "psr-4": { + "LayLink\\": "src/" + } + }, + "require": { + "workerman/workerman": "^5.2" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..ee1e240 --- /dev/null +++ b/composer.lock @@ -0,0 +1,135 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "53b94cb4ffef5114d7bb33e05cec8c31", + "packages": [ + { + "name": "workerman/coroutine", + "version": "v1.1.5", + "source": { + "type": "git", + "url": "https://github.com/workerman-php/coroutine.git", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/workerman-php/coroutine/zipball/b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "workerman/workerman": "^5.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "psr/log": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\": "src", + "Workerman\\Coroutine\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Workerman coroutine", + "support": { + "issues": "https://github.com/workerman-php/coroutine/issues", + "source": "https://github.com/workerman-php/coroutine/tree/v1.1.5" + }, + "time": "2026-03-12T02:07:37+00:00" + }, + { + "name": "workerman/workerman", + "version": "v5.2.0", + "source": { + "type": "git", + "url": "https://github.com/walkor/workerman.git", + "reference": "1d8694c945bc64a5bc11ad753ec7220bcba37cb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/walkor/workerman/zipball/1d8694c945bc64a5bc11ad753ec7220bcba37cb1", + "reference": "1d8694c945bc64a5bc11ad753ec7220bcba37cb1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "workerman/coroutine": "^1.1 || dev-main" + }, + "conflict": { + "ext-swow": " [ + 'node_type' => 'client', + 'token' => 'CHANGE_ME', + 'allowed_cidrs' => [ + '192.168.0.0/16', + '10.10.0.0/16', + ], + 'allowed_ports' => [22, 80, 443, 3306, 5432], + 'enabled' => true, + ], +]; diff --git a/config/policies.php b/config/policies.php new file mode 100644 index 0000000..ab12981 --- /dev/null +++ b/config/policies.php @@ -0,0 +1,22 @@ + 'public-web-egress', + 'users' => ['normal-user', 'admin', 'devops'], + 'target_hosts' => ['*'], + 'target_ports' => [80, 443], + 'protocol' => 'tcp', + 'route_type' => 'direct', + 'enabled' => true, + ], + [ + 'policy_id' => 'public-udp-egress', + 'users' => ['normal-user', 'admin', 'devops'], + 'target_hosts' => ['*'], + 'target_ports' => [53, 123, 443], + 'protocol' => 'udp', + 'route_type' => 'direct', + 'enabled' => true, + ], +]; diff --git a/config/routes.php b/config/routes.php new file mode 100644 index 0000000..b34c24b --- /dev/null +++ b/config/routes.php @@ -0,0 +1,5 @@ + 'deny', +]; diff --git a/contract.md b/contract.md new file mode 100644 index 0000000..e308f99 --- /dev/null +++ b/contract.md @@ -0,0 +1,1241 @@ +# POP-Controlled Reverse Access Gateway Contract + +## Implementation Status + +Last updated: 2026-05-28 Asia/Shanghai. + +Current phase: MVP bootstrap in progress. + +Direction update under evaluation: + +* Legacy agent naming has been migrated to `Client Agent` for the first MVP path. +* First capability being implemented: + * Client connects to Client Agent. + * Client Agent connects outbound to POP Server. + * Client Agent wraps all traffic into LayLink frames. + * POP Server authenticates/authorizes the request. + * POP Server connects directly to the public target. + * POP Server relays target data back to Client Agent through LayLink frames. +* KCP acceleration requirement: + * The Agent-to-POP transport should support a KCP-over-UDP mode. + * TCP framed mode should remain as a fallback/control-friendly transport. + * KCP should be introduced behind a transport abstraction instead of leaking into session, policy, or routing code. +* Transport protocol configuration: + * POP Server uses `POP_ALLOWED_AGENT_TRANSPORTS` to allow one or more Agent-to-POP transports. + * Agent uses `AGENT_TRANSPORT_PROTOCOL` to choose one concrete transport. + * Allowed names are `tcp`, `udp`, and `kcp`. + * Current runnable implementation is `tcp`; `udp` and `kcp` are reserved and must be implemented behind a transport abstraction. +* Feasibility: + * Workerman supports long-running async TCP servers and custom protocols; it is suitable for the framed fallback/control channel. + * KCP itself is a UDP-based reliable ARQ protocol, so adding KCP means adding a UDP transport layer and session demultiplexing below the existing LayLink frame protocol. + * Native PHP KCP support is possible through an extension or FFI binding to `ikcp.c`; pure-PHP KCP is not recommended for production performance. + * Recommended implementation order: + 1. Complete Client Agent naming migration in code, docs, config, and entrypoints. + 2. Implement TCP-framed Client Agent -> POP -> public target path. + 3. Define `TransportInterface` so frame protocol can run over TCP now and KCP later. + 4. Add KCP-over-UDP transport via extension/FFI/proxy after the TCP framed path is stable. + * Main risk: + * KCP is not a socket by itself. It needs UDP I/O, timers, packet flush/update scheduling, MTU handling, retransmission tuning, and connection/session management. + * PHP-only KCP may work as a prototype but is likely CPU-heavy under concurrency. + * The cleanest production path is a PHP extension/FFI binding or a sidecar KCP transport process. + +Completed in this checkpoint: + +* Added Composer PSR-4 autoload for `LayLink\\`. +* Added `.env.example`, `config/nodes.php`, `config/policies.php`, and `config/routes.php`. +* Added Workerman CLI entrypoints: + * `bin/pop-server.php` + * `bin/client-agent.php` +* Added length-prefixed JSON frame protocol: + * `Frame` + * `FrameType` + * `FrameCodec` + * `FrameParser` +* Added POP-side MVP services: + * agent listener with node token auth, heartbeat handling, node registry, and framed session relay +* Added Agent-side MVP client: + * outbound POP connection + * AUTH frame + * heartbeat + * local allowlist enforcement + * target TCP connection and DATA/CLOSE relay +* Added local JSONL audit logger at `runtime/audit.log`. +* Configured Workerman logs and pid files under `runtime/`. +* Added `readme.md` with node type descriptions, per-role `.env` requirements, config examples, and deployment checklist. +* Ran `composer dump-autoload`. +* Verified all non-vendor PHP files with `php -l`. +* Verified PSR-4 autoload by instantiating `LayLink\\Protocol\\Frame`. +* Verified POP Workerman startup with localhost high port outside the sandbox: + * `POP_AGENT_LISTEN=127.0.0.1:19001` + * worker reached `[OK]` and stopped cleanly via `timeout`. +* Added Agent-to-POP transport configuration: + * `POP_ALLOWED_AGENT_TRANSPORTS` + * `AGENT_TRANSPORT_PROTOCOL` + * POP rejects disallowed Agent transport during node authentication with `transport_not_allowed`. +* Renamed the agent entrypoint and defaults: + * Client Agent entrypoint is `bin/client-agent.php` + * default `NODE_ID=client-01` + * default `NODE_TYPE=client` + * runtime pid file `runtime/client-agent.pid` + * worker name `laylink-client-agent` +* Verified `bin/client-agent.php` starts under Workerman and reaches `[OK]` in a short smoke test. +* Reworked new MVP data path: + * local client connects to one enabled Client Agent ingress listener + * Client Agent sends `OPEN` to POP Server + * POP Server authenticates client request and checks policy + * POP Server opens the public target directly + * `DATA` and `CLOSE` frames relay the stream between Client Agent and POP Server +* Added Client Agent local ingress protocols: + * `socks5`: SOCKS5 `CONNECT`; default enabled on `127.0.0.1:1080` + * `http-proxy`: HTTP `CONNECT` and ordinary HTTP absolute URL proxy requests; default disabled on `127.0.0.1:8080` + * `raw-json`: newline JSON debug ingress; default disabled on `127.0.0.1:9000` +* Added per-ingress env switches, listen IPs, and listen ports: + * `CLIENT_AGENT_SOCKS5_ENABLED` + * `CLIENT_AGENT_SOCKS5_LISTEN_IP` + * `CLIENT_AGENT_SOCKS5_LISTEN_PORT` + * `CLIENT_AGENT_SOCKS5_AUTH_MODE` + * `CLIENT_AGENT_SOCKS5_USERNAME` + * `CLIENT_AGENT_SOCKS5_PASSWORD` + * `CLIENT_AGENT_HTTP_PROXY_ENABLED` + * `CLIENT_AGENT_HTTP_PROXY_LISTEN_IP` + * `CLIENT_AGENT_HTTP_PROXY_LISTEN_PORT` + * `CLIENT_AGENT_RAW_JSON_ENABLED` + * `CLIENT_AGENT_RAW_JSON_LISTEN_IP` + * `CLIENT_AGENT_RAW_JSON_LISTEN_PORT` +* Added Client Agent default identity for generated proxy requests: + * `CLIENT_AGENT_AUTH_TOKEN` + * `CLIENT_AGENT_USER_ID` +* Completed SOCKS5 TCP proxy protocol handling for the current MVP: + * method negotiation + * no-auth method + * RFC1929 username/password method + * IPv4/domain/IPv6 target address parsing + * `CONNECT` + * standard SOCKS5 failure replies + * `BIND` returns command-not-supported + * `UDP ASSOCIATE` returns a local UDP relay endpoint and uses LayLink `UDP_DATA` frames +* Added LayLink UDP datagram relay path: + * Client Agent parses SOCKS5 UDP request packets + * Client Agent sends `UDP_DATA` frames to POP Server + * POP Server validates client auth and `protocol=udp` policy + * POP Server sends datagrams to public UDP targets + * POP Server returns UDP responses as `UDP_DATA` +* Added UDP egress sample policy `public-udp-egress` for ports `53`, `123`, and `443`. +* Added `FrameType::descriptions()` as the code-level frame type catalog. +* Verified Client Agent can start SOCKS5, HTTP proxy, and raw-json listeners together on localhost high ports. +* Verified Client Agent can start SOCKS5 TCP plus SOCKS5 UDP relay listeners together on localhost high ports. +* Added `scripts/verify-socks5.sh` to verify real SOCKS5 HTTPS requests: + * `https://bing.com/` for connectivity and HTTPS support + * `https://ip.sb/` for egress IP +* Reorganized `.env.example` into readable sections: + * `[config]` + * `[client-agent]` + * `[pop-server]` + * Section headers are comments-for-humans in practice; the current Env loader ignores lines without `=`. +* Removed deprecated/compatibility-only surfaces: + * `POP_CLIENT_LISTEN` + * POP direct client listener + * `src/Server/ClientListener.php` + * `bin/client-gateway.php` + * `src/Client/ClientGateway.php` + * `bin/border-agent.php` + * sample border node and border policy docs +* Verified POP now starts with only `laylink-pop-agent-listener`. + +Known MVP limitations: + +* The current sandbox cannot bind TCP sockets; startup smoke tests need escalation or a normal shell environment. +* raw-json debug ingress uses newline-delimited JSON before switching to raw tunnel mode. Example: + +```json +{"auth_token":"dev-token","user_id":"admin","target_host":"example.com","target_port":443,"protocol":"tcp"} +``` + +* No TLS yet. +* No production-grade client identity yet; `dev-token` is hardcoded for MVP development. +* No automated integration test harness yet. +* No explicit idle timeout or connect timeout enforcement yet. +* UDP relay is datagram-oriented and currently creates short-lived POP-side UDP sockets per outbound datagram; pooling and stronger timeout accounting are still future work. +* HTTP proxy supports `CONNECT` and ordinary absolute URL HTTP requests; advanced proxy auth and full HTTP/2 proxying are not implemented. + +Next recommended tasks: + +1. Add a local integration harness that starts POP, Client Agent, and a mock TCP echo target, then verifies authorized tunnel, policy denial, and agent local denial. +2. Add configurable client auth token or JWT-ready auth interface. +3. Add target connect timeout and session idle timeout. +4. Add buffer full/drain handling with audit result `buffer_overflow`. +5. Add README quickstart with exact local commands. +6. Optimize UDP relay with POP-side UDP socket pooling. +7. Add UDP association idle timeouts and cleanup. +8. Aggregate UDP audit records per association instead of per datagram. +9. Add UDP and per-user rate limiting. + +## 0. Project Name + +`LayLink` + +This project implements a PHP Workerman-based reverse tunnel gateway. + +The system allows a Client Agent to establish an outbound persistent framed connection to a POP Server. The POP Server authenticates clients, enforces access policy, selects a route, and forwards authorized TCP streams to public Internet targets or later restricted network zones. + +This is **not** a full Layer-3 VPN. It is a policy-controlled Layer-4 reverse access gateway. + +--- + +## 1. Core Architecture + +### 1.1 Node Types + +The MVP contains two core logical node types: + +1. `POP Server` +2. `Client Agent` + +### 1.2 Required Topology + +```text +Client + | + v +POP Server + | + +--> Direct public egress + | + +--> Client Agent framed access +``` + +### 1.3 Network Constraints + +The Client Agent is located on the client side. + +The Client Agent: + +* Accepts local or LAN client connections. +* Initiates outbound connections to `popserver1`, for example `10.1.0.2`. +* Wraps client requests and stream data in LayLink frames. + +The POP Server: + +* Accepts user/client access. +* Maintains persistent connections from Client Agents. +* Performs authentication, authorization, route selection, session management, and auditing. +* Can optionally connect directly to public Internet destinations. + +--- + +## 2. Non-Negotiable Design Principles + +### 2.1 POP Server Owns Policy + +The POP Server is the only component allowed to make authorization decisions. + +Agents must not accept arbitrary user-specified forwarding requests. + +Agents only execute explicit `OPEN` instructions issued by the POP Server after authorization. + +### 2.2 Agents Are Controlled Executors + +Client Agents are controlled executors. + +They may: + +* Authenticate themselves to the POP Server. +* Maintain heartbeat. +* Accept local client connections on explicitly configured local proxy listeners. +* Send `OPEN` instructions to the POP Server. +* Relay stream data. +* Close sessions. + +They must not: + +* Expose a public SOCKS5/HTTP proxy unless explicitly configured and protected. +* Make authorization decisions locally. +* Override POP policy. +* Route traffic outside POP authorization. + +### 2.3 No Full VPN in MVP + +The MVP must not implement TUN/TAP, virtual network interfaces, routing tables, or full Layer-3 VPN behavior. + +The MVP only supports authorized TCP stream forwarding. + +UDP support may be added later. + +--- + +## 3. MVP Scope + +The first implementation must support: + +1. POP Server starts a TCP listener for clients. +2. POP Server starts a TCP listener for agents. +3. Client Agent connects outbound to POP Server. +4. Agent authenticates with `node_id` and `node_token`. +5. Client connects to POP Server and requests access to a target. +6. POP Server checks policy. +7. POP Server selects a route. +8. POP Server sends `OPEN` frame to selected Agent. +9. Agent connects to the target service. +10. POP Server relays bidirectional TCP data between client and agent. +11. Session closes cleanly on either side disconnecting. +12. Audit log records the session. + +MVP does not need: + +* UDP relay. +* Web UI. +* Multi-POP clustering. +* Distributed HA. +* TLS certificate automation. +* SSH command audit. +* Database SQL audit. +* Complex identity provider integration. + +--- + +## 4. Recommended Technology Stack + +Language: + +```text +PHP 8.2+ +``` + +Framework: + +```text +Workerman +``` + +Recommended packages: + +```text +workerman/workerman +monolog/monolog +vlucas/phpdotenv +ramsey/uuid +``` + +Optional later: + +```text +firebase/php-jwt +illuminate/database +react/promise +``` + +--- + +## 5. Directory Structure + +The project should use the following structure: + +```text +pop-tunnel-gateway/ + composer.json + .env.example + README.md + CONTRACT.md + + bin/ + pop-server.php + client-agent.php + + config/ + routes.php + nodes.php + policies.php + + src/ + Protocol/ + Frame.php + FrameType.php + FrameCodec.php + FrameParser.php + + Server/ + PopServer.php + AgentListener.php + + Agent/ + AgentClient.php + TargetConnector.php + + Client/ + ClientGateway.php + + Session/ + TunnelSession.php + SessionManager.php + + Node/ + NodeRegistry.php + NodeConnection.php + + Auth/ + NodeAuthenticator.php + ClientAuthenticator.php + PolicyChecker.php + + Route/ + RouteResolver.php + RouteDecision.php + + Audit/ + AuditLogger.php + + Util/ + BufferLimiter.php + LoggerFactory.php +``` + +--- + +## 6. Frame Protocol + +The system must use a framed protocol between POP Server and Agents. + +Raw stream passthrough between POP and Agent is not allowed because the system needs multiplexing, session IDs, heartbeats, error handling, and auditability. + +### 6.1 Frame Types + +Required frame types: + +| Type | Direction | Meaning | +| --- | --- | --- | +| `AUTH` | Client Agent -> POP | Agent authenticates itself with `node_id`, `node_type`, `node_token`, and `transport_protocol`. | +| `AUTH_OK` | POP -> Client Agent | Agent authentication accepted. | +| `AUTH_FAIL` | POP -> Client Agent | Agent authentication rejected; POP closes the connection after sending this frame. | +| `PING` | Client Agent -> POP | Agent heartbeat with active session count, load, and timestamp. | +| `PONG` | POP -> Client Agent | Heartbeat response. | +| `OPEN` | Client Agent -> POP | Client Agent requests POP to authorize and open a target stream. | +| `OPEN_OK` | POP -> Client Agent | POP has connected the target and the stream can begin. | +| `OPEN_FAIL` | POP -> Client Agent | POP rejected or failed the requested target stream. | +| `DATA` | Bidirectional | Stream bytes for one `session_id`; MVP payload uses base64. | +| `UDP_DATA` | Bidirectional | UDP datagram bytes for one UDP association; MVP payload uses base64 and includes target metadata. | +| `CLOSE` | Bidirectional | Close one stream session. | +| `ERROR` | Bidirectional | Explicit protocol or session error. | +| `WINDOW` | Bidirectional | Reserved flow-control window update for future backpressure. | + +For the new MVP, `OPEN` always means: + +```text +Client Agent asks POP Server to connect to the target. +``` + +It does not mean POP asks Agent to connect to an intranet target. That older direction is reserved for a later executor-agent mode. + +### 6.2 Frame Fields + +Each frame must contain: + +```text +version +type +session_id +payload_length +payload +``` + +Suggested JSON payload for MVP is acceptable. + +Binary optimization may be added later. + +### 6.3 Frame Encoding + +For MVP, use length-prefixed JSON frames. + +Format: + +```text +uint32_be length +json_payload +``` + +Example decoded frame: + +```json +{ + "version": 1, + "type": "DATA", + "session_id": "018f6f4a-xxxx-xxxx", + "payload": "base64-encoded-binary" +} +``` + +For `DATA` frames, binary stream data may be base64 encoded in MVP. + +A later version may replace this with binary headers plus raw binary body. + +--- + +## 7. Agent Authentication + +When an Agent connects to POP Server, it must immediately send an `AUTH` frame. + +Example: + +```json +{ + "version": 1, + "type": "AUTH", + "session_id": null, + "payload": { + "node_id": "client-01", + "node_type": "client", + "node_zone": "corp", + "node_token": "CHANGE_ME", + "supported_protocols": ["tcp"] + } +} +``` + +POP Server must verify: + +```text +node_id exists +node_token matches +node_type matches config +node is not disabled +``` + +On success: + +```json +{ + "version": 1, + "type": "AUTH_OK", + "session_id": null, + "payload": { + "node_id": "client-01", + "heartbeat_interval": 10 + } +} +``` + +On failure: + +```json +{ + "version": 1, + "type": "AUTH_FAIL", + "session_id": null, + "payload": { + "reason": "invalid_node_token" + } +} +``` + +POP Server must close the connection after `AUTH_FAIL`. + +--- + +## 8. Heartbeat + +Agents must send `PING` every 10 seconds by default. + +Example: + +```json +{ + "version": 1, + "type": "PING", + "session_id": null, + "payload": { + "node_id": "client-01", + "active_sessions": 12, + "load": 0.35, + "timestamp": 1710000000 + } +} +``` + +POP Server replies: + +```json +{ + "version": 1, + "type": "PONG", + "session_id": null, + "payload": { + "timestamp": 1710000001 + } +} +``` + +If no heartbeat is received for 30 seconds, POP Server must mark the node as offline and close all sessions routed through that node. + +--- + +## 9. Client Request Model + +For MVP, the client may connect directly to a POP TCP listener and submit an initial JSON request. + +Example: + +```json +{ + "auth_token": "dev-token", + "target_host": "192.168.10.20", + "target_port": 22, + "protocol": "tcp", + "route_hint": "client-01" +} +``` + +After the initial request is accepted, the TCP stream becomes a bidirectional tunnel. + +Later versions may implement: + +```text +SOCKS5 +HTTP CONNECT +WebSocket tunnel +mTLS client identity +JWT authentication +``` + +--- + +## 10. Route Selection + +The POP Server must use `RouteResolver` to decide where traffic should go. + +Possible route types: + +```text +direct +agent +border +reject +``` + +Example route decision: + +```php +[ + 'allowed' => true, + 'route_type' => 'agent', + 'node_id' => 'client-01', + 'policy_id' => 'corp-ssh-admin', +] +``` + +Client `route_hint` is advisory only. + +The POP Server may ignore, override, or reject the route hint. + +--- + +## 11. Policy Rules + +Policies must be defined in `config/policies.php`. + +Example: + +```php +return [ + [ + 'policy_id' => 'corp-ssh-admin', + 'users' => ['admin', 'devops'], + 'target_hosts' => ['192.168.10.20', '192.168.10.21'], + 'target_ports' => [22], + 'route_type' => 'agent', + 'node_id' => 'client-01', + 'enabled' => true, + ], + [ + 'policy_id' => 'public-web-egress', + 'users' => ['normal-user', 'admin'], + 'target_hosts' => ['*'], + 'target_ports' => [80, 443], + 'route_type' => 'direct', + 'enabled' => true, + ], +]; +``` + +Policy matching must consider: + +```text +user identity +target host +target port +protocol +requested route +node availability +policy enabled/disabled state +``` + +Default behavior must be deny. + +--- + +## 12. Agent Local Allowlist + +Each Agent must enforce its own local allowlist. + +Example `config/nodes.php`: + +```php +return [ + 'client-01' => [ + 'node_type' => 'client', + 'token' => 'CHANGE_ME', + 'allowed_cidrs' => [ + '192.168.0.0/16', + '10.10.0.0/16', + ], + 'allowed_ports' => [22, 80, 443, 3306, 5432], + 'enabled' => true, + ], +]; +``` + +If an Agent receives an `OPEN` request outside its local allowlist, it must return `OPEN_FAIL`. + +Example: + +```json +{ + "version": 1, + "type": "OPEN_FAIL", + "session_id": "018f6f4a-xxxx", + "payload": { + "reason": "agent_local_policy_denied" + } +} +``` + +--- + +## 13. Opening a Target Connection + +POP Server sends: + +```json +{ + "version": 1, + "type": "OPEN", + "session_id": "018f6f4a-xxxx", + "payload": { + "target_host": "192.168.10.20", + "target_port": 22, + "protocol": "tcp", + "user_id": "admin", + "policy_id": "corp-ssh-admin" + } +} +``` + +Agent connects to the target. + +On success: + +```json +{ + "version": 1, + "type": "OPEN_OK", + "session_id": "018f6f4a-xxxx", + "payload": { + "target_host": "192.168.10.20", + "target_port": 22 + } +} +``` + +On failure: + +```json +{ + "version": 1, + "type": "OPEN_FAIL", + "session_id": "018f6f4a-xxxx", + "payload": { + "reason": "connection_refused" + } +} +``` + +--- + +## 14. Data Forwarding + +After `OPEN_OK`, data is exchanged with `DATA` frames. + +Example: + +```json +{ + "version": 1, + "type": "DATA", + "session_id": "018f6f4a-xxxx", + "payload": { + "data": "base64-encoded-binary" + } +} +``` + +Both POP Server and Agent must map `session_id` to the corresponding local TCP connection. + +--- + +## 15. Session Lifecycle + +Session states: + +```text +NEW +OPENING +OPEN +CLOSING +CLOSED +FAILED +``` + +State transitions: + +```text +NEW -> OPENING +OPENING -> OPEN +OPENING -> FAILED +OPEN -> CLOSING +CLOSING -> CLOSED +OPEN -> CLOSED +``` + +A session must be closed when: + +```text +client disconnects +target disconnects +agent disconnects +policy check fails +OPEN_FAIL is received +send buffer exceeds hard limit +idle timeout is reached +``` + +--- + +## 16. Timeouts + +Required timeout defaults: + +```text +agent heartbeat interval: 10 seconds +agent offline threshold: 30 seconds +target connect timeout: 5 seconds +session idle timeout: 300 seconds +client initial request timeout: 5 seconds +``` + +All timeout values should be configurable. + +--- + +## 17. Buffer and Backpressure + +The implementation must avoid unbounded memory growth. + +Each Workerman connection should configure a maximum send buffer. + +Suggested default: + +```php +$connection->maxSendBufferSize = 8 * 1024 * 1024; +``` + +The implementation must handle: + +```text +onBufferFull +onBufferDrain +onClose +onError +``` + +If a session exceeds buffer limits and cannot recover, close the session and write an audit log entry. + +--- + +## 18. Audit Logging + +Every session must produce an audit log. + +Required fields: + +```text +session_id +user_id +source_ip +target_host +target_port +protocol +route_type +node_id +policy_id +start_time +end_time +duration_ms +bytes_client_to_target +bytes_target_to_client +result +failure_reason +``` + +MVP may write JSON lines to a local file: + +```text +runtime/audit.log +``` + +Example: + +```json +{ + "session_id": "018f6f4a-xxxx", + "user_id": "admin", + "source_ip": "1.2.3.4", + "target_host": "192.168.10.20", + "target_port": 22, + "protocol": "tcp", + "route_type": "agent", + "node_id": "client-01", + "policy_id": "corp-ssh-admin", + "start_time": "2026-05-28T10:00:00+08:00", + "end_time": "2026-05-28T10:01:00+08:00", + "duration_ms": 60000, + "bytes_client_to_target": 1024, + "bytes_target_to_client": 2048, + "result": "closed", + "failure_reason": null +} +``` + +--- + +## 19. Error Handling + +Errors must be explicit. + +Required error reasons include: + +```text +invalid_frame +invalid_auth +node_not_found +node_offline +policy_denied +route_not_found +target_connect_timeout +target_connection_refused +agent_local_policy_denied +session_not_found +buffer_overflow +protocol_not_supported +internal_error +``` + +Do not silently drop sessions without logging. + +--- + +## 20. Security Requirements + +### 20.1 Required for MVP + +* Node token authentication. +* Default-deny policy. +* Agent local allowlist. +* Audit logging. +* Explicit route decision. +* No arbitrary target access. +* No unauthenticated Agent registration. +* No unauthenticated client request in production mode. + +### 20.2 Required Before Production + +* TLS between Client and POP. +* TLS between Agent and POP. +* Strong node credentials. +* Rotatable node tokens. +* JWT or mTLS client authentication. +* Per-user policy. +* Rate limiting. +* Session concurrency limits. +* Structured audit storage. +* Log redaction for secrets. + +--- + +## 21. Configuration + +`.env.example`: + +```env +APP_ENV=dev +POP_AGENT_LISTEN=0.0.0.0:9001 +NODE_ID=client-01 +NODE_TYPE=client +NODE_TOKEN=CHANGE_ME +POP_SERVER_ADDRESS=tcp://10.1.0.2:9001 +AUDIT_LOG=runtime/audit.log +LOG_LEVEL=debug +``` + +--- + +## 22. CLI Entrypoints + +### 22.1 Start POP Server + +```bash +php bin/pop-server.php start +``` + +### 22.2 Start Client Agent + +```bash +php bin/client-agent.php start +``` + +### 22.3 Stop Services + +```bash +php bin/pop-server.php stop +php bin/client-agent.php stop +``` + +--- + +## 23. MVP Acceptance Tests + +The implementation is acceptable only if the following tests pass. + +### 23.1 Agent Registration + +Given a valid node token, Client Agent connects to POP Server and becomes online. + +Expected result: + +```text +NodeRegistry contains client-01 as online. +``` + +### 23.2 Invalid Agent Rejected + +Given an invalid node token, POP Server returns `AUTH_FAIL` and closes the connection. + +Expected result: + +```text +Node is not registered. +Audit/security log records invalid_auth. +``` + +### 23.3 Authorized TCP Tunnel + +Given: + +```text +Client requests 192.168.10.20:22 +User is allowed by policy +client-01 is online +``` + +Expected result: + +```text +POP sends OPEN to client-01. +Agent connects to 192.168.10.20:22. +Client can exchange TCP data with target. +Audit log records success. +``` + +### 23.4 Policy Denial + +Given: + +```text +Client requests 192.168.99.99:22 +No policy allows this target +``` + +Expected result: + +```text +POP rejects the request. +No OPEN frame is sent to Agent. +Audit log records policy_denied. +``` + +### 23.5 Agent Local Denial + +Given: + +```text +POP sends OPEN to a target outside Agent local allowlist +``` + +Expected result: + +```text +Agent returns OPEN_FAIL with agent_local_policy_denied. +Session is closed. +Audit log records failure. +``` + +### 23.6 Agent Offline + +Given: + +```text +client-01 is offline +Client requests route through client-01 +``` + +Expected result: + +```text +POP rejects request with node_offline. +``` + +### 23.7 Clean Close + +Given: + +```text +Client closes connection +``` + +Expected result: + +```text +POP sends CLOSE to Agent. +Agent closes target connection. +SessionManager removes session. +Audit log records closed. +``` + +### 23.8 Target Connection Failure + +Given: + +```text +Target host or port is unreachable +``` + +Expected result: + +```text +Agent sends OPEN_FAIL. +POP closes client connection. +Audit log records target connection failure. +``` + +--- + +## 24. Implementation Priority + +Implement in this order: + +1. `Frame`, `FrameCodec`, `FrameParser` +2. `NodeAuthenticator` +3. `NodeRegistry` +4. Agent connection listener on POP Server +5. Client Agent outbound connection +6. Heartbeat +7. Client listener +8. Policy checker +9. Route resolver +10. Session manager +11. Agent target connector +12. DATA forwarding +13. CLOSE handling +14. Audit logger +15. Basic tests + +Do not implement UDP transport, KCP transport, Web UI, or clustering before the MVP TCP-framed proxy path is stable. + +--- + +## 25. Coding Rules + +* Use strict types where possible. +* Keep protocol parsing separate from business logic. +* Do not mix policy checking with socket forwarding. +* Do not use global mutable arrays except Workerman bootstrap state if unavoidable. +* All session IDs must be unique. +* Every network error must be logged. +* Every rejected access must be auditable. +* All config values must be externalized. +* The system must run on Linux. +* The code must be readable and modular, not a single giant script. + +--- + +## 26. Deliverables + +Codex should generate: + +```text +composer.json +.env.example +README.md +bin/pop-server.php +bin/client-agent.php +config/routes.php +config/nodes.php +config/policies.php +src/**/*.php +``` + +The generated code must be runnable with: + +```bash +composer install +php bin/pop-server.php start +php bin/client-agent.php start +``` + +--- + +## 27. Out of Scope for First Version + +The following features must not be implemented in the first version unless explicitly requested later: + +```text +TUN/TAP VPN +Layer-3 routing +Kernel packet capture +UDP relay +QUIC +Web dashboard +Clustered POP Server +Redis-based session sharing +TLS certificate automation +SSH command recording +Database SQL auditing +Browser-based remote desktop +``` + +--- + +## 28. Final Goal + +The final MVP should prove this flow: + +```text +Client + -> POP Server + -> policy check + -> Client Agent + -> internal TCP service +``` + +The POP Server must remain the only policy authority. + +Agents must remain controlled executors. + +Default access must always be denied unless a policy explicitly allows it. diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..ab15ee7 --- /dev/null +++ b/readme.md @@ -0,0 +1,373 @@ +# LayLink + +LayLink 是一个基于 PHP Workerman 的策略控制型四层反向访问网关。 + +它不是 VPN。客户端连接 Client Agent,请求访问某个 TCP 目标;Client Agent 使用 LayLink Frame 协议连接 POP Server;POP Server 负责认证、策略判断、连接公网目标和审计。 + +## 当前节点类型 + +当前 MVP 分成 2 种核心类型: + +1. `POP Server` +2. `Client Agent` + +## 配置文件关系 + +`.env` 用来配置当前进程自己的运行参数。 + +`config/nodes.php` 用来声明 POP Server 认可哪些 Agent 节点,以及 Agent 的本地 allowlist。 + +`config/policies.php` 用来声明客户端访问策略。POP Server 根据这个文件决定某个用户是否允许访问某个目标,以及是否由 POP Server 直接连接公网目标。 + +`.env.example` 是示例模板。实际部署时建议复制为 `.env`,再按当前进程类型修改: + +```bash +cp .env.example .env +``` + +`.env.example` 中的 `[config]`、`[client-agent]`、`[pop-server]` 是阅读分组标题,当前加载器会忽略这些标题,只读取 `KEY=value` 配置行。 + +## POP Server + +POP Server 是控制面和转发入口。 + +它负责: + +* 监听 Agent 长连接。 +* 校验 Agent 的 `NODE_ID` 和 `NODE_TOKEN`。 +* 校验客户端访问请求。 +* 根据 `config/policies.php` 选择路由。 +* 向 Agent 下发 `OPEN` 指令。 +* 记录审计日志。 + +启动入口: + +```bash +php bin/pop-server.php start +``` + +POP Server 需要配置这些 `.env`: + +```env +APP_ENV=dev +POP_AGENT_LISTEN=0.0.0.0:9001 +POP_ALLOWED_AGENT_TRANSPORTS=tcp +AUDIT_LOG=runtime/audit.log +LOG_LEVEL=debug +``` + +配置说明: + +| 变量 | 作用 | 常见值 | +| --- | --- | --- | +| `APP_ENV` | 当前运行环境。开发时使用 `dev`,生产可使用 `prod`。 | `dev`、`test`、`prod` | +| `POP_AGENT_LISTEN` | POP Server 给 Client Agent 连接的监听地址。Agent 的 `POP_SERVER_ADDRESS` 应指向这里。 | `0.0.0.0:9001`、`127.0.0.1:9001` | +| `POP_ALLOWED_AGENT_TRANSPORTS` | POP Server 允许 Agent 使用的底层传输协议,逗号分隔。Agent 认证时会上报自己的选择,不在列表内会被拒绝。 | `tcp`、`tcp,kcp`、`tcp,udp,kcp` | +| `AUDIT_LOG` | 审计日志路径。MVP 使用 JSON Lines 追加写入。 | `runtime/audit.log` | +| `LOG_LEVEL` | 日志级别预留配置。当前 MVP 主要为后续日志工厂使用。 | `debug`、`info`、`warning`、`error` | + +POP Server 通常不需要配置 `NODE_ID`、`NODE_TYPE`、`NODE_TOKEN`、`POP_SERVER_ADDRESS`。这些是 Agent 进程使用的。 + +## Client Agent + +Client Agent 部署在客户端侧,作为本机或局域网入口。 + +它负责: + +* 主动出站连接 POP Server。 +* 使用 `NODE_ID`、`NODE_TYPE`、`NODE_TOKEN` 向 POP Server 认证。 +* 维持心跳。 +* 接收本地客户端连接。 +* 将客户端请求和数据封装为 LayLink Frame。 +* 通过选定的底层传输协议把 Frame 发送给 POP Server。 +* 接收 POP Server 返回的目标数据并转发回本地客户端。 + +启动入口: + +```bash +php bin/client-agent.php start +``` + +Client Agent 需要配置这些 `.env`: + +```env +APP_ENV=dev +NODE_ID=client-01 +NODE_TYPE=client +NODE_TOKEN=CHANGE_ME +AGENT_TRANSPORT_PROTOCOL=tcp +CLIENT_AGENT_AUTH_TOKEN=dev-token +CLIENT_AGENT_USER_ID=admin +CLIENT_AGENT_SOCKS5_ENABLED=true +CLIENT_AGENT_SOCKS5_LISTEN_IP=127.0.0.1 +CLIENT_AGENT_SOCKS5_LISTEN_PORT=1080 +CLIENT_AGENT_SOCKS5_UDP_LISTEN_IP=127.0.0.1 +CLIENT_AGENT_SOCKS5_UDP_LISTEN_PORT=1081 +CLIENT_AGENT_SOCKS5_UDP_ADVERTISE_IP=127.0.0.1 +CLIENT_AGENT_SOCKS5_AUTH_MODE=no-auth +CLIENT_AGENT_SOCKS5_USERNAME= +CLIENT_AGENT_SOCKS5_PASSWORD= +CLIENT_AGENT_HTTP_PROXY_ENABLED=false +CLIENT_AGENT_HTTP_PROXY_LISTEN_IP=127.0.0.1 +CLIENT_AGENT_HTTP_PROXY_LISTEN_PORT=8080 +CLIENT_AGENT_RAW_JSON_ENABLED=false +CLIENT_AGENT_RAW_JSON_LISTEN_IP=127.0.0.1 +CLIENT_AGENT_RAW_JSON_LISTEN_PORT=9000 +POP_SERVER_ADDRESS=tcp://10.1.0.2:9001 +LOG_LEVEL=debug +``` + +配置说明: + +| 变量 | 作用 | 常见值 | +| --- | --- | --- | +| `APP_ENV` | 当前运行环境。 | `dev`、`test`、`prod` | +| `NODE_ID` | 当前 Client Agent 的节点 ID。必须存在于 `config/nodes.php`。 | `client-01` | +| `NODE_TYPE` | 当前节点类型。Client Agent 必须配置为 `client`。 | `client` | +| `NODE_TOKEN` | 当前节点认证密钥。必须和 `config/nodes.php` 中同一 `NODE_ID` 的 `token` 一致。 | 强随机字符串,开发时可临时用 `CHANGE_ME` | +| `AGENT_TRANSPORT_PROTOCOL` | 当前 Agent 到 POP Server 使用的底层传输协议。必须被 POP Server 的 `POP_ALLOWED_AGENT_TRANSPORTS` 允许。 | `tcp`、`udp`、`kcp` | +| `CLIENT_AGENT_AUTH_TOKEN` | SOCKS5/HTTP 代理入口生成 `OPEN` 帧时使用的客户端认证 token。 | `dev-token`,生产应替换 | +| `CLIENT_AGENT_USER_ID` | SOCKS5/HTTP 代理入口生成 `OPEN` 帧时使用的默认用户 ID。 | `admin`、`normal-user` | +| `CLIENT_AGENT_SOCKS5_ENABLED` | 是否启用 SOCKS5 本地入口。 | `true`、`false` | +| `CLIENT_AGENT_SOCKS5_LISTEN_IP` | SOCKS5 本地入口监听 IP,默认只允许本机访问。 | `127.0.0.1`、`0.0.0.0` | +| `CLIENT_AGENT_SOCKS5_LISTEN_PORT` | SOCKS5 本地入口监听端口。 | `1080` | +| `CLIENT_AGENT_SOCKS5_UDP_LISTEN_IP` | SOCKS5 UDP ASSOCIATE 本地 UDP relay 监听 IP。 | `127.0.0.1`、`0.0.0.0` | +| `CLIENT_AGENT_SOCKS5_UDP_LISTEN_PORT` | SOCKS5 UDP ASSOCIATE 本地 UDP relay 监听端口。 | `1081` | +| `CLIENT_AGENT_SOCKS5_UDP_ADVERTISE_IP` | UDP ASSOCIATE 回复给应用的 UDP relay IP。 | `127.0.0.1`、Client Agent 局域网 IP | +| `CLIENT_AGENT_SOCKS5_AUTH_MODE` | SOCKS5 认证模式。`no-auth` 使用无认证,`userpass` 使用 RFC1929 用户名/密码认证。 | `no-auth`、`userpass` | +| `CLIENT_AGENT_SOCKS5_USERNAME` | SOCKS5 用户名,仅 `userpass` 模式使用。 | 自定义用户名 | +| `CLIENT_AGENT_SOCKS5_PASSWORD` | SOCKS5 密码,仅 `userpass` 模式使用。 | 强随机密码 | +| `CLIENT_AGENT_HTTP_PROXY_ENABLED` | 是否启用 HTTP 代理本地入口,支持 `CONNECT` 和普通 HTTP 绝对 URL 请求。 | `true`、`false` | +| `CLIENT_AGENT_HTTP_PROXY_LISTEN_IP` | HTTP 代理本地入口监听 IP,默认只允许本机访问。 | `127.0.0.1`、`0.0.0.0` | +| `CLIENT_AGENT_HTTP_PROXY_LISTEN_PORT` | HTTP 代理本地入口监听端口。 | `8080`、`7890` | +| `CLIENT_AGENT_RAW_JSON_ENABLED` | 是否启用 raw-json 调试入口。 | `true`、`false` | +| `CLIENT_AGENT_RAW_JSON_LISTEN_IP` | raw-json 调试入口监听 IP。 | `127.0.0.1` | +| `CLIENT_AGENT_RAW_JSON_LISTEN_PORT` | raw-json 调试入口监听端口。 | `9000` | +| `POP_SERVER_ADDRESS` | POP Server 的 Agent 监听地址。必须带 `tcp://`。 | `tcp://10.1.0.2:9001`、`tcp://127.0.0.1:9001` | +| `LOG_LEVEL` | 日志级别预留配置。 | `debug`、`info`、`warning`、`error` | + +Client Agent 的节点身份不是只写在 `.env` 中,POP Server 侧还必须在 `config/nodes.php` 中声明同名节点: + +```php +'client-01' => [ + 'node_type' => 'client', + 'token' => 'CHANGE_ME', + 'allowed_cidrs' => [ + '192.168.0.0/16', + '10.10.0.0/16', + ], + 'allowed_ports' => [22, 80, 443, 3306, 5432], + 'enabled' => true, +], +``` + +当前 `allowed_cidrs` 和 `allowed_ports` 仍保留给后续 Agent 侧直连目标能力;新的最小路径会优先让 POP Server 直连公网目标。 + +当前 MVP 提供三种本地入口: + +| 入口 | 默认状态 | 默认监听 | 适用场景 | +| --- | --- | --- | --- | +| SOCKS5 | 开启 | `127.0.0.1:1080` | 只能配置 SOCKS5 代理的应用。 | +| HTTP 代理 | 关闭 | `127.0.0.1:8080` | 支持 HTTP proxy 或 HTTP CONNECT 的应用。 | +| raw-json | 关闭 | `127.0.0.1:9000` | 开发调试,手工发送一行 JSON。 | + +只能用 SOCKS5 的应用可直接配置: + +```text +SOCKS5 Host: 127.0.0.1 +SOCKS5 Port: 1080 +``` + +SOCKS5 当前支持: + +| 能力 | 状态 | +| --- | --- | +| 方法协商 | 支持 | +| 无认证 `0x00` | 支持 | +| 用户名/密码 `0x02`,RFC1929 | 支持 | +| IPv4 地址 | 支持 | +| 域名地址 | 支持 | +| IPv6 地址 | 支持 | +| `CONNECT` | 支持 | +| `BIND` | 按协议返回 command not supported | +| `UDP ASSOCIATE` | 支持,经 LayLink `UDP_DATA` Frame 转发到 POP Server | + +SOCKS5 UDP 转发路径: + +```text +App UDP + -> Client Agent UDP relay + -> UDP_DATA Frame over Agent transport + -> POP Server + -> Public UDP target +``` + +UDP 访问仍然由 POP Server 的 `config/policies.php` 控制。默认示例允许 `53`、`123`、`443`: + +```php +[ + 'policy_id' => 'public-udp-egress', + 'users' => ['normal-user', 'admin', 'devops'], + 'target_hosts' => ['*'], + 'target_ports' => [53, 123, 443], + 'protocol' => 'udp', + 'route_type' => 'direct', + 'enabled' => true, +], +``` + +启用 SOCKS5 用户名密码认证: + +```env +CLIENT_AGENT_SOCKS5_AUTH_MODE=userpass +CLIENT_AGENT_SOCKS5_USERNAME=alice +CLIENT_AGENT_SOCKS5_PASSWORD=change-this-password +``` + +如果启用 raw-json,客户端连接 raw-json 端口并发送一行 JSON: + +```json +{"auth_token":"dev-token","user_id":"admin","target_host":"example.com","target_port":443,"protocol":"tcp"} +``` + +字段说明: + +| 字段 | 作用 | 常见值 | +| --- | --- | --- | +| `auth_token` | 客户端认证 token。当前 MVP 固定为 `dev-token`。 | `dev-token` | +| `user_id` | 用户身份。POP Server 会用它匹配 `config/policies.php`。 | `admin`、`devops`、`normal-user` | +| `target_host` | 目标主机。 | `192.168.10.20`、`example.com` | +| `target_port` | 目标端口。 | `22`、`80`、`443`、`5432` | +| `protocol` | 目标协议。当前只支持 TCP。 | `tcp` | +| `route_hint` | 预留字段。新的最小路径由 POP Server 直连公网目标,通常不需要填写。 | `null` | + +## 策略如何配置 + +客户端访问是否允许,由 `config/policies.php` 决定。 + +示例: + +```php +[ + 'policy_id' => 'public-web-egress', + 'users' => ['normal-user', 'admin', 'devops'], + 'target_hosts' => ['*'], + 'target_ports' => [80, 443], + 'protocol' => 'tcp', + 'route_type' => 'direct', + 'enabled' => true, +], +``` + +这条策略表示: + +* `normal-user`、`admin` 和 `devops` 可以访问任意主机的 `80`、`443` 端口。 +* Client Agent 只负责把请求封装成 Frame 发到 POP Server。 +* POP Server 校验策略后直接连接公网目标。 + +路由类型: + +| `route_type` | 作用 | +| --- | --- | +| `direct` | POP Server 直接连接目标,适合公共互联网出口。 | +| `reject` | 拒绝访问。默认行为就是拒绝。 | + +## 本地开发示例 + +一个最小本地开发 `.env` 可以这样写: + +```env +APP_ENV=dev +POP_AGENT_LISTEN=127.0.0.1:9001 +POP_ALLOWED_AGENT_TRANSPORTS=tcp +NODE_ID=client-01 +NODE_TYPE=client +NODE_TOKEN=CHANGE_ME +AGENT_TRANSPORT_PROTOCOL=tcp +CLIENT_AGENT_AUTH_TOKEN=dev-token +CLIENT_AGENT_USER_ID=admin +CLIENT_AGENT_SOCKS5_ENABLED=true +CLIENT_AGENT_SOCKS5_LISTEN_IP=127.0.0.1 +CLIENT_AGENT_SOCKS5_LISTEN_PORT=1080 +CLIENT_AGENT_SOCKS5_UDP_LISTEN_IP=127.0.0.1 +CLIENT_AGENT_SOCKS5_UDP_LISTEN_PORT=1081 +CLIENT_AGENT_SOCKS5_UDP_ADVERTISE_IP=127.0.0.1 +CLIENT_AGENT_SOCKS5_AUTH_MODE=no-auth +CLIENT_AGENT_SOCKS5_USERNAME= +CLIENT_AGENT_SOCKS5_PASSWORD= +CLIENT_AGENT_HTTP_PROXY_ENABLED=false +CLIENT_AGENT_HTTP_PROXY_LISTEN_IP=127.0.0.1 +CLIENT_AGENT_HTTP_PROXY_LISTEN_PORT=8080 +CLIENT_AGENT_RAW_JSON_ENABLED=false +CLIENT_AGENT_RAW_JSON_LISTEN_IP=127.0.0.1 +CLIENT_AGENT_RAW_JSON_LISTEN_PORT=9000 +POP_SERVER_ADDRESS=tcp://127.0.0.1:9001 +AUDIT_LOG=runtime/audit.log +LOG_LEVEL=debug +``` + +## Agent 到 POP 的传输协议 + +Agent 到 POP Server 的业务数据始终使用 LayLink 自定义 Frame 协议封装。`AGENT_TRANSPORT_PROTOCOL` 只决定这些 Frame 运行在哪种底层传输上。 + +当前规划的传输类型: + +| 值 | 含义 | 当前状态 | +| --- | --- | --- | +| `tcp` | Frame over TCP,最容易部署和调试。 | 已实现 | +| `udp` | Frame over UDP,需要额外处理可靠性、顺序和丢包。 | 已预留,未实现 | +| `kcp` | Frame over KCP/UDP,用 KCP 做可靠、低延迟传输。 | 已预留,未实现 | + +POP Server 用 `POP_ALLOWED_AGENT_TRANSPORTS` 控制允许哪些传输协议。例如: + +```env +POP_ALLOWED_AGENT_TRANSPORTS=tcp,kcp +``` + +Client Agent 用 `AGENT_TRANSPORT_PROTOCOL` 选择自己实际使用哪种协议。例如: + +```env +AGENT_TRANSPORT_PROTOCOL=tcp +``` + +如果 Agent 选择的协议不在 POP 允许列表中,POP 会在认证阶段返回 `AUTH_FAIL`,原因是 `transport_not_allowed`。 + +当前代码只实现了 `tcp`。如果 Agent 配置为 `udp` 或 `kcp`,进程会启动失败并明确提示该传输尚未实现。 + +启动 POP Server: + +```bash +php bin/pop-server.php start +``` + +另一个终端启动 Client Agent: + +```bash +php bin/client-agent.php start +``` + +然后把应用的代理设置为 SOCKS5 `127.0.0.1:1080`。Client Agent 会解析 SOCKS5 `CONNECT`,封装成 `OPEN` 帧发给 POP Server;POP Server 校验通过后直连公网目标,随后通过 `DATA` 帧转发原始 TCP 数据。 + +验证 SOCKS5 HTTPS 联通性和出口 IP: + +```bash +scripts/verify-socks5.sh +``` + +默认使用 `127.0.0.1:1080`。如果启用了 SOCKS5 用户名密码: + +```bash +SOCKS5_USER=alice SOCKS5_PASSWORD=change-this-password scripts/verify-socks5.sh +``` + +## 部署检查清单 + +部署前至少确认: + +* `NODE_TOKEN` 已替换为强随机密钥。 +* `config/nodes.php` 中的 `token` 和 Agent `.env` 中的 `NODE_TOKEN` 一致。 +* `NODE_TYPE` 和 `config/nodes.php` 中的 `node_type` 一致。 +* Agent 的 `allowed_cidrs` 和 `allowed_ports` 足够窄。 +* `config/policies.php` 不存在过宽的 `target_hosts` 和 `target_ports`。 +* 生产环境不要继续使用固定的 `dev-token` 客户端认证。 +* 生产环境应补充 TLS、JWT 或 mTLS、限流和更完整的审计存储。 diff --git a/runtime/workerman.log b/runtime/workerman.log new file mode 100644 index 0000000..8b07f27 --- /dev/null +++ b/runtime/workerman.log @@ -0,0 +1,36 @@ +2026-05-28 10:19:07 pid:478270 Workerman[pop-server.php] start in DEBUG mode +2026-05-28 10:19:09 pid:478270 Workerman[pop-server.php] received signal SIGTERM +2026-05-28 10:19:09 pid:478270 Workerman[pop-server.php] stopping +2026-05-28 10:19:09 pid:478270 Workerman[pop-server.php] has been stopped +2026-05-28 11:13:28 pid:481004 Workerman[client-agent.php] start in DEBUG mode +2026-05-28 11:13:30 pid:481004 Workerman[client-agent.php] received signal SIGTERM +2026-05-28 11:13:30 pid:481004 Workerman[client-agent.php] stopping +2026-05-28 11:13:30 pid:481004 Workerman[client-agent.php] has been stopped +2026-05-28 11:21:31 pid:482154 Workerman[client-agent.php] start in DEBUG mode +2026-05-28 11:21:32 pid:482187 Workerman[pop-server.php] start in DEBUG mode +2026-05-28 11:21:33 pid:482154 Workerman[client-agent.php] received signal SIGTERM +2026-05-28 11:21:33 pid:482154 Workerman[client-agent.php] stopping +2026-05-28 11:21:33 pid:482154 Workerman[client-agent.php] has been stopped +2026-05-28 11:21:34 pid:482187 Workerman[pop-server.php] received signal SIGTERM +2026-05-28 11:21:34 pid:482187 Workerman[pop-server.php] stopping +2026-05-28 11:21:34 pid:482187 Workerman[pop-server.php] has been stopped +2026-05-28 11:32:55 pid:484077 Workerman[client-agent.php] start in DEBUG mode +2026-05-28 11:32:57 pid:484077 Workerman[client-agent.php] received signal SIGTERM +2026-05-28 11:32:57 pid:484077 Workerman[client-agent.php] stopping +2026-05-28 11:32:57 pid:484077 Workerman[client-agent.php] has been stopped +2026-05-28 11:34:04 pid:484561 Workerman[client-agent.php] start in DEBUG mode +2026-05-28 11:34:06 pid:484561 Workerman[client-agent.php] received signal SIGTERM +2026-05-28 11:34:06 pid:484561 Workerman[client-agent.php] stopping +2026-05-28 11:34:06 pid:484561 Workerman[client-agent.php] has been stopped +2026-05-28 11:36:48 pid:485045 Workerman[client-agent.php] start in DEBUG mode +2026-05-28 11:36:50 pid:485045 Workerman[client-agent.php] received signal SIGTERM +2026-05-28 11:36:50 pid:485045 Workerman[client-agent.php] stopping +2026-05-28 11:36:50 pid:485045 Workerman[client-agent.php] has been stopped +2026-05-28 11:41:26 pid:486188 Workerman[client-agent.php] start in DEBUG mode +2026-05-28 11:41:28 pid:486188 Workerman[client-agent.php] received signal SIGTERM +2026-05-28 11:41:28 pid:486188 Workerman[client-agent.php] stopping +2026-05-28 11:41:28 pid:486188 Workerman[client-agent.php] has been stopped +2026-05-28 12:08:09 pid:489103 Workerman[pop-server.php] start in DEBUG mode +2026-05-28 12:08:11 pid:489103 Workerman[pop-server.php] received signal SIGTERM +2026-05-28 12:08:11 pid:489103 Workerman[pop-server.php] stopping +2026-05-28 12:08:11 pid:489103 Workerman[pop-server.php] has been stopped diff --git a/scripts/verify-socks5.sh b/scripts/verify-socks5.sh new file mode 100755 index 0000000..22e7f0b --- /dev/null +++ b/scripts/verify-socks5.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -u + +SOCKS5_HOST="${SOCKS5_HOST:-127.0.0.1}" +SOCKS5_PORT="${SOCKS5_PORT:-1080}" +SOCKS5_USER="${SOCKS5_USER:-}" +SOCKS5_PASSWORD="${SOCKS5_PASSWORD:-}" +CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-10}" +MAX_TIME="${MAX_TIME:-30}" + +proxy="socks5h://${SOCKS5_HOST}:${SOCKS5_PORT}" +auth=() +if [[ -n "${SOCKS5_USER}" || -n "${SOCKS5_PASSWORD}" ]]; then + auth=(--proxy-user "${SOCKS5_USER}:${SOCKS5_PASSWORD}") +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "ERR curl_not_found" + exit 127 +fi + +echo "Using SOCKS5 proxy: ${proxy}" + +echo +echo "[1/2] HTTPS connectivity: https://bing.com/" +bing_code="$( + curl \ + --silent \ + --show-error \ + --location \ + --output /dev/null \ + --write-out '%{http_code}' \ + --proxy "${proxy}" \ + "${auth[@]}" \ + --connect-timeout "${CONNECT_TIMEOUT}" \ + --max-time "${MAX_TIME}" \ + https://bing.com/ +)" +curl_status=$? +if [[ ${curl_status} -ne 0 ]]; then + echo "ERR bing_request_failed status=${curl_status}" + exit "${curl_status}" +fi + +echo "bing_http_code=${bing_code}" +if [[ ! "${bing_code}" =~ ^(200|301|302|307|308)$ ]]; then + echo "ERR bing_unexpected_status" + exit 1 +fi + +echo +echo "[2/2] Egress IP: https://ip.sb/" +egress_ip="$( + curl \ + --silent \ + --show-error \ + --location \ + --proxy "${proxy}" \ + "${auth[@]}" \ + --connect-timeout "${CONNECT_TIMEOUT}" \ + --max-time "${MAX_TIME}" \ + https://ip.sb/ +)" +curl_status=$? +if [[ ${curl_status} -ne 0 ]]; then + echo "ERR ip_sb_request_failed status=${curl_status}" + exit "${curl_status}" +fi + +egress_ip="$(printf '%s' "${egress_ip}" | tr -d '[:space:]')" +echo "egress_ip=${egress_ip}" + +if [[ ! "${egress_ip}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ && ! "${egress_ip}" =~ : ]]; then + echo "ERR ip_sb_unexpected_response" + exit 1 +fi + +echo +echo "OK socks5_https_verified" diff --git a/src/Agent/AgentClient.php b/src/Agent/AgentClient.php new file mode 100644 index 0000000..c9121d1 --- /dev/null +++ b/src/Agent/AgentClient.php @@ -0,0 +1,753 @@ + */ + private array $initialBuffers = []; + /** @var array */ + private array $connectionSessionIds = []; + /** @var array */ + private array $clients = []; + /** @var array */ + private array $sessionStates = []; + /** @var array */ + private array $pendingData = []; + /** @var array */ + private array $connectionStages = []; + /** @var array */ + private array $sessionIngressProtocols = []; + /** @var array */ + private array $udpClients = []; + /** @var array */ + private array $udpSessions = []; + + public function __construct( + private readonly string $clientListen, + private readonly string $ingressProtocol, + private readonly string $popAddress, + private readonly string $nodeId, + private readonly string $nodeType, + private readonly string $nodeToken, + private readonly string $nodeZone, + private readonly string $transportProtocol, + private readonly string $clientAuthToken, + private readonly string $defaultUserId, + private readonly string $socks5AuthMode = 'no-auth', + private readonly string $socks5Username = '', + private readonly string $socks5Password = '', + private readonly ?string $socks5UdpListen = null, + private readonly string $socks5UdpAdvertiseIp = '127.0.0.1', + ) { + } + + public function boot(string $workerName): void + { + $worker = new Worker('tcp://' . $this->clientListen); + $worker->name = $workerName; + $worker->count = 1; + $worker->onWorkerStart = function (): void { + if ($this->transportProtocol !== 'tcp') { + throw new \RuntimeException("Agent transport '{$this->transportProtocol}' is configured but not implemented yet."); + } + $this->connect(); + Timer::add(10, fn () => $this->heartbeat()); + }; + $worker->onConnect = fn (TcpConnection $connection) => $this->onClientConnect($connection); + $worker->onMessage = fn (TcpConnection $connection, string $data) => $this->onClientMessage($connection, $data); + $worker->onClose = fn (TcpConnection $connection) => $this->onClientClose($connection); + + if ($this->ingressProtocol === 'socks5' && $this->socks5UdpListen !== null) { + $udpWorker = new Worker('udp://' . $this->socks5UdpListen); + $udpWorker->name = $workerName . '-udp'; + $udpWorker->count = 1; + $udpWorker->onMessage = fn (UdpConnection $connection, string $data) => $this->onSocks5UdpMessage($connection, $data); + } + } + + private function connect(): void + { + $this->parser = new FrameParser(); + $this->authenticated = false; + $connection = new AsyncTcpConnection($this->popAddress); + $connection->maxSendBufferSize = 8 * 1024 * 1024; + $this->pop = $connection; + + $connection->onConnect = function (AsyncTcpConnection $connection): void { + $this->send(new Frame(FrameType::AUTH, null, [ + 'node_id' => $this->nodeId, + 'node_type' => $this->nodeType, + 'node_zone' => $this->nodeZone, + 'node_token' => $this->nodeToken, + 'transport_protocol' => $this->transportProtocol, + 'supported_protocols' => ['tcp'], + 'supported_transports' => ['tcp', 'udp', 'kcp'], + ])); + }; + $connection->onMessage = function (AsyncTcpConnection $connection, string $data): void { + try { + foreach ($this->parser?->push($data) ?? [] as $frame) { + $this->handleFrame($frame); + } + } catch (\Throwable $e) { + $connection->close(); + } + }; + $connection->onClose = function (): void { + $this->authenticated = false; + foreach ($this->clients as $client) { + $client->close(); + } + $this->initialBuffers = []; + $this->connectionSessionIds = []; + $this->clients = []; + $this->sessionStates = []; + $this->pendingData = []; + $this->connectionStages = []; + $this->sessionIngressProtocols = []; + Timer::add(3, fn () => $this->connect(), [], false); + }; + $connection->connect(); + } + + private function handleFrame(Frame $frame): void + { + match ($frame->type) { + FrameType::AUTH_OK => $this->authenticated = true, + FrameType::AUTH_FAIL => $this->pop?->close(), + FrameType::PONG => null, + FrameType::OPEN_OK => $this->openClientSession($frame), + FrameType::OPEN_FAIL => $this->failClientSession($frame), + FrameType::DATA => $this->forwardToClient($frame), + FrameType::UDP_DATA => $this->forwardUdpToClient($frame), + FrameType::CLOSE => $this->closeClient($frame->sessionId), + default => null, + }; + } + + private function onClientConnect(TcpConnection $connection): void + { + $connection->maxSendBufferSize = 8 * 1024 * 1024; + $this->initialBuffers[$connection->id] = ''; + $this->connectionStages[$connection->id] = 'init'; + } + + private function onClientMessage(TcpConnection $connection, string $data): void + { + if (!isset($this->connectionSessionIds[$connection->id])) { + $this->handleInitialRequest($connection, $data); + return; + } + + $sessionId = $this->connectionSessionIds[$connection->id]; + if (($this->sessionStates[$sessionId] ?? null) !== 'open') { + $this->pendingData[$sessionId] = ($this->pendingData[$sessionId] ?? '') . $data; + return; + } + + $this->send(new Frame(FrameType::DATA, $sessionId, ['data' => base64_encode($data)])); + } + + private function handleInitialRequest(TcpConnection $connection, string $data): void + { + if (!$this->authenticated || $this->pop === null) { + $connection->send("ERR pop_not_connected\n"); + $connection->close(); + return; + } + + $buffer = ($this->initialBuffers[$connection->id] ?? '') . $data; + $this->initialBuffers[$connection->id] = $buffer; + + match ($this->ingressProtocol) { + 'raw-json' => $this->handleRawJsonInitialRequest($connection), + 'socks5' => $this->handleSocks5InitialRequest($connection), + 'http-proxy' => $this->handleHttpProxyInitialRequest($connection), + default => $this->failLocalClient($connection, 'unsupported_ingress_protocol'), + }; + } + + private function handleRawJsonInitialRequest(TcpConnection $connection): void + { + $buffer = $this->initialBuffers[$connection->id] ?? ''; + $payloadBytes = ''; + $requestText = null; + + if (($pos = strpos($buffer, "\n")) !== false) { + $requestText = substr($buffer, 0, $pos); + $payloadBytes = substr($buffer, $pos + 1); + } elseif (($decoded = json_decode(trim($buffer), true)) && is_array($decoded)) { + $requestText = trim($buffer); + } + + if ($requestText === null) { + if (strlen($buffer) > 8192) { + $connection->send("ERR invalid_frame\n"); + $connection->close(); + } else { + $this->initialBuffers[$connection->id] = $buffer; + } + return; + } + + unset($this->initialBuffers[$connection->id]); + $request = json_decode(trim($requestText), true); + if (!is_array($request)) { + $connection->send("ERR invalid_frame\n"); + $connection->close(); + return; + } + + $this->startPopSession($connection, [ + 'auth_token' => (string)($request['auth_token'] ?? ''), + 'user_id' => (string)($request['user_id'] ?? ''), + 'target_host' => (string)($request['target_host'] ?? ''), + 'target_port' => (int)($request['target_port'] ?? 0), + 'protocol' => (string)($request['protocol'] ?? 'tcp'), + 'route_hint' => $request['route_hint'] ?? null, + ], $payloadBytes, 'raw-json'); + } + + private function handleSocks5InitialRequest(TcpConnection $connection): void + { + $buffer = $this->initialBuffers[$connection->id] ?? ''; + $stage = $this->connectionStages[$connection->id] ?? 'init'; + + if ($stage === 'init') { + if (strlen($buffer) < 2) { + return; + } + $version = ord($buffer[0]); + $methods = ord($buffer[1]); + if ($version !== 5) { + $this->failLocalClient($connection, 'invalid_socks_version'); + return; + } + if (strlen($buffer) < 2 + $methods) { + return; + } + + $offeredMethods = array_map('ord', str_split(substr($buffer, 2, $methods))); + $selectedMethod = $this->selectSocks5AuthMethod($offeredMethods); + if ($selectedMethod === null) { + $connection->send("\x05\xff"); + $connection->close(); + return; + } + + $connection->send("\x05" . chr($selectedMethod)); + $buffer = substr($buffer, 2 + $methods); + $this->initialBuffers[$connection->id] = $buffer; + $this->connectionStages[$connection->id] = $selectedMethod === 2 ? 'auth' : 'request'; + if ($buffer === '') { + return; + } + $stage = $this->connectionStages[$connection->id]; + } + + if ($stage === 'auth') { + if (strlen($buffer) < 2) { + return; + } + if (ord($buffer[0]) !== 1) { + $connection->send("\x01\x01"); + $connection->close(); + return; + } + $usernameLength = ord($buffer[1]); + if (strlen($buffer) < 2 + $usernameLength + 1) { + return; + } + $username = substr($buffer, 2, $usernameLength); + $passwordLengthOffset = 2 + $usernameLength; + $passwordLength = ord($buffer[$passwordLengthOffset]); + if (strlen($buffer) < $passwordLengthOffset + 1 + $passwordLength) { + return; + } + $password = substr($buffer, $passwordLengthOffset + 1, $passwordLength); + if (!hash_equals($this->socks5Username, $username) || !hash_equals($this->socks5Password, $password)) { + $connection->send("\x01\x01"); + $connection->close(); + return; + } + + $connection->send("\x01\x00"); + $buffer = substr($buffer, $passwordLengthOffset + 1 + $passwordLength); + $this->initialBuffers[$connection->id] = $buffer; + $this->connectionStages[$connection->id] = 'request'; + if ($buffer === '') { + return; + } + } + + if (strlen($buffer) < 4) { + return; + } + + $version = ord($buffer[0]); + $command = ord($buffer[1]); + $reserved = ord($buffer[2]); + $addressType = ord($buffer[3]); + if ($version !== 5 || $reserved !== 0) { + $connection->send($this->socks5Reply(1)); + $connection->close(); + return; + } + if ($command !== 1 && $command !== 3) { + $connection->send($this->socks5Reply(7)); + $connection->close(); + return; + } + + $offset = 4; + if ($addressType === 1) { + if (strlen($buffer) < $offset + 4 + 2) { + return; + } + $host = inet_ntop(substr($buffer, $offset, 4)); + $offset += 4; + } elseif ($addressType === 3) { + if (strlen($buffer) < $offset + 1) { + return; + } + $length = ord($buffer[$offset]); + $offset++; + if (strlen($buffer) < $offset + $length + 2) { + return; + } + $host = substr($buffer, $offset, $length); + $offset += $length; + } elseif ($addressType === 4) { + if (strlen($buffer) < $offset + 16 + 2) { + return; + } + $host = inet_ntop(substr($buffer, $offset, 16)); + $offset += 16; + } else { + $connection->send($this->socks5Reply(8)); + $connection->close(); + return; + } + + $port = unpack('nport', substr($buffer, $offset, 2))['port']; + $offset += 2; + unset($this->initialBuffers[$connection->id], $this->connectionStages[$connection->id]); + if ($command === 3) { + $connection->send($this->socks5UdpAssociateReply()); + return; + } + $this->startPopSession($connection, [ + 'auth_token' => $this->clientAuthToken, + 'user_id' => $this->defaultUserId, + 'target_host' => (string)$host, + 'target_port' => (int)$port, + 'protocol' => 'tcp', + ], substr($buffer, $offset), 'socks5'); + } + + private function handleHttpProxyInitialRequest(TcpConnection $connection): void + { + $buffer = $this->initialBuffers[$connection->id] ?? ''; + $headerEnd = strpos($buffer, "\r\n\r\n"); + if ($headerEnd === false) { + if (strlen($buffer) > 65536) { + $this->failLocalClient($connection, 'invalid_http_proxy_request'); + } + return; + } + + $headers = substr($buffer, 0, $headerEnd + 4); + $body = substr($buffer, $headerEnd + 4); + $lineEnd = strpos($headers, "\r\n"); + if ($lineEnd === false) { + $this->failLocalClient($connection, 'invalid_http_proxy_request'); + return; + } + + $requestLine = substr($headers, 0, $lineEnd); + $parts = explode(' ', $requestLine, 3); + if (count($parts) !== 3) { + $this->failLocalClient($connection, 'invalid_http_proxy_request'); + return; + } + + [$method, $target, $version] = $parts; + unset($this->initialBuffers[$connection->id], $this->connectionStages[$connection->id]); + + if (strtoupper($method) === 'CONNECT') { + [$host, $port] = $this->splitHostPort($target, 443); + $this->startPopSession($connection, [ + 'auth_token' => $this->clientAuthToken, + 'user_id' => $this->defaultUserId, + 'target_host' => $host, + 'target_port' => $port, + 'protocol' => 'tcp', + ], '', 'http-connect'); + return; + } + + $url = parse_url($target); + if (!is_array($url) || !isset($url['host'])) { + $this->failLocalClient($connection, 'unsupported_http_proxy_request'); + return; + } + + $scheme = strtolower((string)($url['scheme'] ?? 'http')); + $port = (int)($url['port'] ?? ($scheme === 'https' ? 443 : 80)); + $path = (string)($url['path'] ?? '/'); + if (isset($url['query'])) { + $path .= '?' . $url['query']; + } + $rewritten = $method . ' ' . $path . ' ' . $version . substr($headers, $lineEnd) . $body; + + $this->startPopSession($connection, [ + 'auth_token' => $this->clientAuthToken, + 'user_id' => $this->defaultUserId, + 'target_host' => (string)$url['host'], + 'target_port' => $port, + 'protocol' => 'tcp', + ], $rewritten, 'http-proxy'); + } + + private function startPopSession(TcpConnection $connection, array $request, string $payloadBytes, string $ingressProtocol): void + { + $sessionId = Uuid::v4(); + $this->connectionSessionIds[$connection->id] = $sessionId; + $this->clients[$sessionId] = $connection; + $this->sessionStates[$sessionId] = 'opening'; + $this->sessionIngressProtocols[$sessionId] = $ingressProtocol; + if ($payloadBytes !== '') { + $this->pendingData[$sessionId] = $payloadBytes; + } + + $this->send(new Frame(FrameType::OPEN, $sessionId, [ + 'auth_token' => (string)($request['auth_token'] ?? ''), + 'user_id' => (string)($request['user_id'] ?? ''), + 'target_host' => (string)($request['target_host'] ?? ''), + 'target_port' => (int)($request['target_port'] ?? 0), + 'protocol' => (string)($request['protocol'] ?? 'tcp'), + 'route_hint' => $request['route_hint'] ?? null, + 'source_ip' => (string)($connection->getRemoteIp() ?? ''), + ])); + } + + private function onSocks5UdpMessage(UdpConnection $connection, string $data): void + { + if (!$this->authenticated || $this->pop === null || strlen($data) < 10) { + return; + } + + $clientAddress = $connection->getRemoteAddress(); + $this->udpClients[$clientAddress] = $connection; + + $offset = 0; + $reserved = substr($data, $offset, 2); + $offset += 2; + $fragment = ord($data[$offset]); + $offset++; + $addressType = ord($data[$offset]); + $offset++; + + if ($reserved !== "\x00\x00" || $fragment !== 0) { + return; + } + + $parsed = $this->parseSocksAddress($data, $offset, $addressType); + if ($parsed === null) { + return; + } + + [$host, $port, $offset] = $parsed; + $payload = substr($data, $offset); + $sessionId = $this->udpSessionId($clientAddress, $host, $port); + $this->udpSessions[$sessionId] = [ + 'client_address' => $clientAddress, + 'target_host' => $host, + 'target_port' => $port, + ]; + + $this->send(new Frame(FrameType::UDP_DATA, $sessionId, [ + 'auth_token' => $this->clientAuthToken, + 'user_id' => $this->defaultUserId, + 'target_host' => $host, + 'target_port' => $port, + 'protocol' => 'udp', + 'source_ip' => $connection->getRemoteIp(), + 'data' => base64_encode($payload), + ])); + } + + private function forwardUdpToClient(Frame $frame): void + { + if ($frame->sessionId === null || !isset($this->udpSessions[$frame->sessionId])) { + return; + } + + $session = $this->udpSessions[$frame->sessionId]; + $clientAddress = $session['client_address']; + $client = $this->udpClients[$clientAddress] ?? null; + $data = base64_decode((string)($frame->payload['data'] ?? ''), true); + if ($client === null || $data === false) { + return; + } + + $client->send($this->packSocks5UdpPacket( + (string)($frame->payload['target_host'] ?? $session['target_host']), + (int)($frame->payload['target_port'] ?? $session['target_port']), + $data, + )); + } + + private function openClientSession(Frame $frame): void + { + if ($frame->sessionId === null || !isset($this->clients[$frame->sessionId])) { + return; + } + + $this->sessionStates[$frame->sessionId] = 'open'; + $ingressProtocol = $this->sessionIngressProtocols[$frame->sessionId] ?? 'raw-json'; + if ($ingressProtocol === 'raw-json') { + $this->clients[$frame->sessionId]->send("OK\n"); + } elseif ($ingressProtocol === 'socks5') { + $this->clients[$frame->sessionId]->send($this->socks5Reply(0)); + } elseif ($ingressProtocol === 'http-connect') { + $this->clients[$frame->sessionId]->send("HTTP/1.1 200 Connection Established\r\n\r\n"); + } + $pending = $this->pendingData[$frame->sessionId] ?? ''; + unset($this->pendingData[$frame->sessionId]); + if ($pending !== '') { + $this->send(new Frame(FrameType::DATA, $frame->sessionId, ['data' => base64_encode($pending)])); + } + } + + private function failClientSession(Frame $frame): void + { + if ($frame->sessionId === null || !isset($this->clients[$frame->sessionId])) { + return; + } + + $reason = (string)($frame->payload['reason'] ?? 'open_failed'); + $ingressProtocol = $this->sessionIngressProtocols[$frame->sessionId] ?? 'raw-json'; + if ($ingressProtocol === 'socks5') { + $this->clients[$frame->sessionId]->send($this->socks5Reply($this->socks5ReplyCodeForReason($reason))); + } elseif (str_starts_with($ingressProtocol, 'http')) { + $this->clients[$frame->sessionId]->send("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\nX-LayLink-Error: {$reason}\r\n\r\n"); + } else { + $this->clients[$frame->sessionId]->send("ERR {$reason}\n"); + } + $this->closeClient($frame->sessionId); + } + + private function forwardToClient(Frame $frame): void + { + if ($frame->sessionId === null || !isset($this->clients[$frame->sessionId])) { + $this->send(new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'session_not_found'])); + return; + } + + $data = base64_decode((string)($frame->payload['data'] ?? ''), true); + if ($data === false) { + $this->send(new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_frame'])); + return; + } + + $this->clients[$frame->sessionId]->send($data); + } + + private function onClientClose(TcpConnection $connection): void + { + unset($this->initialBuffers[$connection->id]); + unset($this->connectionStages[$connection->id]); + if (!isset($this->connectionSessionIds[$connection->id])) { + return; + } + + $sessionId = $this->connectionSessionIds[$connection->id]; + unset($this->connectionSessionIds[$connection->id]); + unset($this->clients[$sessionId], $this->sessionStates[$sessionId], $this->pendingData[$sessionId], $this->sessionIngressProtocols[$sessionId]); + $this->send(new Frame(FrameType::CLOSE, $sessionId, ['reason' => 'client_closed'])); + } + + private function closeClient(?string $sessionId): void + { + if ($sessionId === null || !isset($this->clients[$sessionId])) { + return; + } + + $connection = $this->clients[$sessionId]; + unset($this->clients[$sessionId], $this->sessionStates[$sessionId], $this->pendingData[$sessionId], $this->sessionIngressProtocols[$sessionId]); + unset($this->connectionSessionIds[$connection->id]); + $connection->close(); + } + + private function failLocalClient(TcpConnection $connection, string $reason): void + { + if ($this->ingressProtocol === 'socks5') { + $connection->send($this->socks5Reply(1)); + } elseif ($this->ingressProtocol === 'http-proxy') { + $connection->send("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nX-LayLink-Error: {$reason}\r\n\r\n"); + } else { + $connection->send("ERR {$reason}\n"); + } + $connection->close(); + } + + /** + * @param int[] $offeredMethods + */ + private function selectSocks5AuthMethod(array $offeredMethods): ?int + { + $mode = strtolower($this->socks5AuthMode); + if (in_array($mode, ['userpass', 'username-password', 'password'], true)) { + return in_array(2, $offeredMethods, true) ? 2 : null; + } + + return in_array(0, $offeredMethods, true) ? 0 : null; + } + + private function socks5Reply(int $replyCode): string + { + return "\x05" . chr($replyCode) . "\x00\x01\x00\x00\x00\x00\x00\x00"; + } + + private function socks5UdpAssociateReply(): string + { + if ($this->socks5UdpListen === null) { + return $this->socks5Reply(7); + } + + [$listenIp, $listenPort] = $this->splitHostPort($this->socks5UdpListen, 0); + $ip = $this->socks5UdpAdvertiseIp !== '' ? $this->socks5UdpAdvertiseIp : $listenIp; + return "\x05\x00\x00" . $this->packSocksAddress($ip, $listenPort); + } + + /** + * @return array{0: string, 1: int, 2: int}|null + */ + private function parseSocksAddress(string $buffer, int $offset, int $addressType): ?array + { + if ($addressType === 1) { + if (strlen($buffer) < $offset + 4 + 2) { + return null; + } + $host = inet_ntop(substr($buffer, $offset, 4)); + $offset += 4; + } elseif ($addressType === 3) { + if (strlen($buffer) < $offset + 1) { + return null; + } + $length = ord($buffer[$offset]); + $offset++; + if (strlen($buffer) < $offset + $length + 2) { + return null; + } + $host = substr($buffer, $offset, $length); + $offset += $length; + } elseif ($addressType === 4) { + if (strlen($buffer) < $offset + 16 + 2) { + return null; + } + $host = inet_ntop(substr($buffer, $offset, 16)); + $offset += 16; + } else { + return null; + } + + $port = unpack('nport', substr($buffer, $offset, 2))['port']; + $offset += 2; + + return [(string)$host, (int)$port, $offset]; + } + + private function packSocks5UdpPacket(string $host, int $port, string $data): string + { + return "\x00\x00\x00" . $this->packSocksAddress($host, $port) . $data; + } + + private function packSocksAddress(string $host, int $port): string + { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return "\x01" . inet_pton($host) . pack('n', $port); + } + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return "\x04" . inet_pton($host) . pack('n', $port); + } + + return "\x03" . chr(strlen($host)) . $host . pack('n', $port); + } + + private function udpSessionId(string $clientAddress, string $host, int $port): string + { + return hash('sha256', $this->nodeId . '|' . $clientAddress . '|' . $host . '|' . $port); + } + + private function socks5ReplyCodeForReason(string $reason): int + { + return match ($reason) { + 'policy_denied', 'invalid_auth' => 2, + 'node_offline', 'route_not_found' => 3, + 'target_connection_refused' => 5, + 'protocol_not_supported', 'route_not_supported' => 7, + default => 1, + }; + } + + /** + * @return array{0: string, 1: int} + */ + private function splitHostPort(string $target, int $defaultPort): array + { + if (str_starts_with($target, '[')) { + $end = strpos($target, ']'); + if ($end !== false) { + $host = substr($target, 1, $end - 1); + $port = substr($target, $end + 1); + return [$host, str_starts_with($port, ':') ? (int)substr($port, 1) : $defaultPort]; + } + } + + $parts = explode(':', $target); + if (count($parts) >= 2) { + $port = array_pop($parts); + return [implode(':', $parts), (int)$port]; + } + + return [$target, $defaultPort]; + } + + private function heartbeat(): void + { + if (!$this->authenticated || $this->pop === null) { + return; + } + + $this->send(new Frame(FrameType::PING, null, [ + 'node_id' => $this->nodeId, + 'active_sessions' => count($this->clients), + 'load' => sys_getloadavg()[0] ?? 0.0, + 'timestamp' => time(), + ])); + } + + private function send(Frame $frame): void + { + $this->pop?->send(FrameCodec::encode($frame)); + } +} diff --git a/src/Agent/TargetConnector.php b/src/Agent/TargetConnector.php new file mode 100644 index 0000000..72418f9 --- /dev/null +++ b/src/Agent/TargetConnector.php @@ -0,0 +1,29 @@ +nodeConfig['allowed_ports'] ?? [], true)) { + return false; + } + + foreach ($this->nodeConfig['allowed_cidrs'] ?? [] as $cidr) { + if (is_string($cidr) && PolicyChecker::cidrContains($cidr, $host)) { + return true; + } + } + + return in_array($host, $this->nodeConfig['allowed_hosts'] ?? [], true); + } +} diff --git a/src/Audit/AuditLogger.php b/src/Audit/AuditLogger.php new file mode 100644 index 0000000..dc31013 --- /dev/null +++ b/src/Audit/AuditLogger.php @@ -0,0 +1,28 @@ +path); + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + } + + public function write(array $record): void + { + $record += [ + 'end_time' => date(DATE_ATOM), + ]; + file_put_contents( + $this->path, + json_encode($record, JSON_UNESCAPED_SLASHES) . PHP_EOL, + FILE_APPEND | LOCK_EX, + ); + } +} diff --git a/src/Auth/ClientAuthenticator.php b/src/Auth/ClientAuthenticator.php new file mode 100644 index 0000000..5a8c953 --- /dev/null +++ b/src/Auth/ClientAuthenticator.php @@ -0,0 +1,18 @@ + false, 'reason' => 'invalid_auth']; + } + + return ['ok' => true, 'user_id' => (string)($request['user_id'] ?? 'admin')]; + } +} diff --git a/src/Auth/NodeAuthenticator.php b/src/Auth/NodeAuthenticator.php new file mode 100644 index 0000000..a7bc7b6 --- /dev/null +++ b/src/Auth/NodeAuthenticator.php @@ -0,0 +1,45 @@ +nodes[$nodeId])) { + return ['ok' => false, 'reason' => 'node_not_found']; + } + + $node = $this->nodes[$nodeId]; + if (($node['enabled'] ?? false) !== true) { + return ['ok' => false, 'reason' => 'node_disabled']; + } + if (($node['node_type'] ?? '') !== $nodeType) { + return ['ok' => false, 'reason' => 'node_type_mismatch']; + } + if (!hash_equals((string)($node['token'] ?? ''), $token)) { + return ['ok' => false, 'reason' => 'invalid_node_token']; + } + if (!in_array($transport, $this->allowedTransports, true)) { + return ['ok' => false, 'reason' => 'transport_not_allowed']; + } + + return ['ok' => true, 'node' => $node + ['node_id' => $nodeId]]; + } +} diff --git a/src/Auth/PolicyChecker.php b/src/Auth/PolicyChecker.php new file mode 100644 index 0000000..03b4495 --- /dev/null +++ b/src/Auth/PolicyChecker.php @@ -0,0 +1,66 @@ +policies as $policy) { + if (($policy['enabled'] ?? false) !== true) { + continue; + } + if (!in_array($userId, $policy['users'] ?? [], true)) { + continue; + } + if (($policy['protocol'] ?? 'tcp') !== $protocol) { + continue; + } + if (!in_array($port, $policy['target_ports'] ?? [], true)) { + continue; + } + if (!$this->hostMatches($host, $policy['target_hosts'] ?? [])) { + continue; + } + + return $policy; + } + + return null; + } + + private function hostMatches(string $host, array $patterns): bool + { + foreach ($patterns as $pattern) { + if ($pattern === '*' || $pattern === $host) { + return true; + } + if (is_string($pattern) && str_contains($pattern, '/') && self::cidrContains($pattern, $host)) { + return true; + } + } + + return false; + } + + public static function cidrContains(string $cidr, string $host): bool + { + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { + return false; + } + + [$subnet, $bits] = array_pad(explode('/', $cidr, 2), 2, null); + if ($bits === null || filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { + return false; + } + + $mask = -1 << (32 - (int)$bits); + return (ip2long($host) & $mask) === (ip2long($subnet) & $mask); + } +} diff --git a/src/Node/NodeConnection.php b/src/Node/NodeConnection.php new file mode 100644 index 0000000..1196fa5 --- /dev/null +++ b/src/Node/NodeConnection.php @@ -0,0 +1,22 @@ +lastHeartbeat = time(); + } +} diff --git a/src/Node/NodeRegistry.php b/src/Node/NodeRegistry.php new file mode 100644 index 0000000..00dbc59 --- /dev/null +++ b/src/Node/NodeRegistry.php @@ -0,0 +1,48 @@ + */ + private array $nodes = []; + + public function register(NodeConnection $node): void + { + $this->nodes[$node->nodeId] = $node; + } + + public function unregisterByConnection(TcpConnection $connection): ?NodeConnection + { + foreach ($this->nodes as $nodeId => $node) { + if ($node->connection === $connection) { + unset($this->nodes[$nodeId]); + return $node; + } + } + + return null; + } + + public function get(string $nodeId): ?NodeConnection + { + return $this->nodes[$nodeId] ?? null; + } + + public function isOnline(string $nodeId): bool + { + return isset($this->nodes[$nodeId]); + } + + /** + * @return NodeConnection[] + */ + public function all(): array + { + return array_values($this->nodes); + } +} diff --git a/src/Protocol/Frame.php b/src/Protocol/Frame.php new file mode 100644 index 0000000..aac9eed --- /dev/null +++ b/src/Protocol/Frame.php @@ -0,0 +1,26 @@ + $this->version, + 'type' => $this->type, + 'session_id' => $this->sessionId, + 'payload' => $this->payload, + ]; + } +} diff --git a/src/Protocol/FrameCodec.php b/src/Protocol/FrameCodec.php new file mode 100644 index 0000000..6109e9a --- /dev/null +++ b/src/Protocol/FrameCodec.php @@ -0,0 +1,47 @@ +toArray(), JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new InvalidArgumentException('invalid_frame_payload'); + } + + return pack('N', strlen($json)) . $json; + } + + public static function decode(string $json): Frame + { + $data = json_decode($json, true); + if (!is_array($data) || !isset($data['type']) || !is_string($data['type'])) { + throw new InvalidArgumentException('invalid_frame'); + } + + $sessionId = $data['session_id'] ?? null; + if ($sessionId !== null && !is_string($sessionId)) { + throw new InvalidArgumentException('invalid_session_id'); + } + + $payload = $data['payload'] ?? []; + if (!is_array($payload)) { + throw new InvalidArgumentException('invalid_payload'); + } + + return new Frame( + $data['type'], + $sessionId, + $payload, + (int)($data['version'] ?? 1), + ); + } +} diff --git a/src/Protocol/FrameParser.php b/src/Protocol/FrameParser.php new file mode 100644 index 0000000..c558b20 --- /dev/null +++ b/src/Protocol/FrameParser.php @@ -0,0 +1,38 @@ +buffer .= $chunk; + $frames = []; + + while (strlen($this->buffer) >= 4) { + $header = unpack('Nlength', substr($this->buffer, 0, 4)); + $length = (int)$header['length']; + if ($length < 1 || $length > FrameCodec::MAX_FRAME_LENGTH) { + throw new RuntimeException('invalid_frame_length'); + } + if (strlen($this->buffer) < 4 + $length) { + break; + } + + $json = substr($this->buffer, 4, $length); + $this->buffer = substr($this->buffer, 4 + $length); + $frames[] = FrameCodec::decode($json); + } + + return $frames; + } +} diff --git a/src/Protocol/FrameType.php b/src/Protocol/FrameType.php new file mode 100644 index 0000000..6e80278 --- /dev/null +++ b/src/Protocol/FrameType.php @@ -0,0 +1,44 @@ + + */ + public static function descriptions(): array + { + return [ + self::AUTH => 'Agent authenticates to POP Server.', + self::AUTH_OK => 'POP Server accepts Agent authentication.', + self::AUTH_FAIL => 'POP Server rejects Agent authentication.', + self::PING => 'Agent heartbeat to POP Server.', + self::PONG => 'POP Server heartbeat response.', + self::OPEN => 'Client Agent asks POP Server to open an authorized target stream.', + self::OPEN_OK => 'POP Server confirms target stream is open.', + self::OPEN_FAIL => 'POP Server rejects or fails target stream opening.', + self::DATA => 'Bidirectional stream bytes, base64 encoded in MVP.', + self::UDP_DATA => 'Bidirectional UDP datagram relay, base64 encoded in MVP.', + self::CLOSE => 'Either side closes a stream session.', + self::ERROR => 'Explicit protocol or session error.', + self::WINDOW => 'Flow-control window update, reserved for backpressure.', + ]; + } +} diff --git a/src/Route/RouteDecision.php b/src/Route/RouteDecision.php new file mode 100644 index 0000000..de304c0 --- /dev/null +++ b/src/Route/RouteDecision.php @@ -0,0 +1,17 @@ +policyChecker->find($userId, $host, $port, $protocol); + if ($policy === null) { + return new RouteDecision(false, 'reject', null, null, 'policy_denied'); + } + + $routeType = (string)($policy['route_type'] ?? 'reject'); + $nodeId = $policy['node_id'] ?? null; + if (($routeType === 'agent' || $routeType === 'border') && $protocol !== 'udp') { + $nodeId = is_string($nodeId) && $nodeId !== '' ? $nodeId : $routeHint; + if (!is_string($nodeId) || $nodeId === '') { + return new RouteDecision(false, 'reject', null, (string)$policy['policy_id'], 'route_not_found'); + } + if (!$this->nodeRegistry->isOnline($nodeId)) { + return new RouteDecision(false, 'reject', $nodeId, (string)$policy['policy_id'], 'node_offline'); + } + } + + return new RouteDecision(true, $routeType, $nodeId, (string)$policy['policy_id']); + } +} diff --git a/src/Server/AgentListener.php b/src/Server/AgentListener.php new file mode 100644 index 0000000..3b5fe1c --- /dev/null +++ b/src/Server/AgentListener.php @@ -0,0 +1,349 @@ + */ + private array $parsers = []; + /** @var array */ + private array $connectionNodeIds = []; + + public function __construct( + Worker $worker, + private readonly NodeAuthenticator $authenticator, + private readonly ClientAuthenticator $clientAuthenticator, + private readonly RouteResolver $routes, + private readonly NodeRegistry $nodes, + private readonly SessionManager $sessions, + private readonly AuditLogger $audit, + ) { + $worker->onConnect = fn (TcpConnection $connection) => $this->onConnect($connection); + $worker->onMessage = fn (TcpConnection $connection, string $data) => $this->onMessage($connection, $data); + $worker->onClose = fn (TcpConnection $connection) => $this->onClose($connection); + $worker->onWorkerStart = fn () => Timer::add(10, fn () => $this->sweepHeartbeats()); + } + + private function onConnect(TcpConnection $connection): void + { + $connection->maxSendBufferSize = 8 * 1024 * 1024; + $this->parsers[$connection->id] = new FrameParser(); + } + + private function onMessage(TcpConnection $connection, string $data): void + { + try { + foreach ($this->parsers[$connection->id]->push($data) as $frame) { + $this->handleFrame($connection, $frame); + } + } catch (\Throwable $e) { + $this->send($connection, new Frame(FrameType::ERROR, null, ['reason' => 'invalid_frame'])); + $connection->close(); + } + } + + private function handleFrame(TcpConnection $connection, Frame $frame): void + { + if ($frame->type === FrameType::AUTH) { + $result = $this->authenticator->authenticate($frame->payload); + if (!$result['ok']) { + $this->send($connection, new Frame(FrameType::AUTH_FAIL, null, ['reason' => $result['reason']])); + $connection->close(); + return; + } + + $nodeId = (string)$frame->payload['node_id']; + $node = new NodeConnection( + $nodeId, + (string)$frame->payload['node_type'], + (string)($frame->payload['node_zone'] ?? 'default'), + $connection, + ); + $this->nodes->register($node); + $this->connectionNodeIds[$connection->id] = $nodeId; + $this->send($connection, new Frame(FrameType::AUTH_OK, null, [ + 'node_id' => $nodeId, + 'heartbeat_interval' => 10, + ])); + echo "Agent online: {$nodeId}\n"; + return; + } + + $nodeId = $this->connectionNodeIds[$connection->id] ?? null; + if (!is_string($nodeId) || $this->nodes->get($nodeId) === null) { + $this->send($connection, new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_auth'])); + $connection->close(); + return; + } + + if ($frame->type === FrameType::PING) { + $node = $this->nodes->get($nodeId); + if ($node !== null) { + $node->lastHeartbeat = time(); + $node->activeSessions = (int)($frame->payload['active_sessions'] ?? $node->activeSessions); + } + $this->send($connection, new Frame(FrameType::PONG, null, ['timestamp' => time()])); + return; + } + + if ($frame->type === FrameType::OPEN) { + $this->openTargetForAgent($connection, $nodeId, $frame); + return; + } + + if ($frame->type === FrameType::UDP_DATA) { + $this->forwardUdpDatagram($connection, $nodeId, $frame); + return; + } + + $session = $frame->sessionId === null ? null : $this->sessions->get($frame->sessionId); + if ($session === null) { + $this->send($connection, new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'session_not_found'])); + return; + } + + match ($frame->type) { + FrameType::DATA => $this->forwardDataToTarget($session, (string)($frame->payload['data'] ?? '')), + FrameType::CLOSE => $this->closeSession($session, 'closed', null), + default => null, + }; + } + + private function forwardUdpDatagram(TcpConnection $agentConnection, string $nodeId, Frame $frame): void + { + if ($frame->sessionId === null) { + $this->send($agentConnection, new Frame(FrameType::ERROR, null, ['reason' => 'invalid_frame'])); + return; + } + + $auth = $this->clientAuthenticator->authenticate($frame->payload); + if (!$auth['ok']) { + $this->send($agentConnection, new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_auth'])); + return; + } + + $host = (string)($frame->payload['target_host'] ?? ''); + $port = (int)($frame->payload['target_port'] ?? 0); + $data = base64_decode((string)($frame->payload['data'] ?? ''), true); + if ($host === '' || $port < 1 || $port > 65535 || $data === false) { + $this->send($agentConnection, new Frame(FrameType::ERROR, $frame->sessionId, ['reason' => 'invalid_frame'])); + return; + } + + $decision = $this->routes->resolve((string)$auth['user_id'], $host, $port, 'udp', $frame->payload['route_hint'] ?? null); + if (!$decision->allowed || $decision->routeType !== 'direct') { + $this->send($agentConnection, new Frame(FrameType::ERROR, $frame->sessionId, [ + 'reason' => $decision->reason ?? 'policy_denied', + ])); + return; + } + + $target = new AsyncUdpConnection("udp://{$host}:{$port}"); + $target->onConnect = fn (AsyncUdpConnection $target) => $target->send($data); + $target->onMessage = function (AsyncUdpConnection $target, string $response) use ($agentConnection, $frame, $host, $port): void { + $this->send($agentConnection, new Frame(FrameType::UDP_DATA, $frame->sessionId, [ + 'target_host' => $host, + 'target_port' => $port, + 'protocol' => 'udp', + 'data' => base64_encode($response), + ])); + $target->close(); + }; + Timer::add(5, fn () => $target->close(), [], false); + $target->connect(); + } + + private function openTargetForAgent(TcpConnection $agentConnection, string $nodeId, Frame $frame): void + { + if ($frame->sessionId === null || $this->sessions->get($frame->sessionId) !== null) { + $this->send($agentConnection, new Frame(FrameType::OPEN_FAIL, $frame->sessionId, ['reason' => 'invalid_frame'])); + return; + } + + $auth = $this->clientAuthenticator->authenticate($frame->payload); + if (!$auth['ok']) { + $this->rejectOpen($agentConnection, $frame, 'invalid_auth', 'anonymous', $nodeId); + return; + } + + $host = (string)($frame->payload['target_host'] ?? ''); + $port = (int)($frame->payload['target_port'] ?? 0); + $protocol = (string)($frame->payload['protocol'] ?? 'tcp'); + if ($host === '' || $port < 1 || $port > 65535 || $protocol !== 'tcp') { + $this->rejectOpen($agentConnection, $frame, 'protocol_not_supported', (string)$auth['user_id'], $nodeId); + return; + } + + $decision = $this->routes->resolve((string)$auth['user_id'], $host, $port, $protocol, $frame->payload['route_hint'] ?? null); + if (!$decision->allowed) { + $this->rejectOpen($agentConnection, $frame, $decision->reason ?? 'policy_denied', (string)$auth['user_id'], $nodeId, $decision->policyId); + return; + } + if ($decision->routeType !== 'direct') { + $this->rejectOpen($agentConnection, $frame, 'route_not_supported', (string)$auth['user_id'], $nodeId, $decision->policyId); + return; + } + + $session = new TunnelSession( + $frame->sessionId, + (string)$auth['user_id'], + (string)($frame->payload['source_ip'] ?? $agentConnection->getRemoteIp() ?? ''), + $host, + $port, + $protocol, + 'direct', + $decision->policyId, + ); + $session->nodeId = $nodeId; + $session->agent = $agentConnection; + $session->state = TunnelSession::OPENING; + $this->sessions->add($session); + + $target = new AsyncTcpConnection("tcp://{$host}:{$port}"); + $target->maxSendBufferSize = 8 * 1024 * 1024; + $session->target = $target; + + $target->onConnect = function () use ($session, $agentConnection): void { + $session->state = TunnelSession::OPEN; + $this->send($agentConnection, new Frame(FrameType::OPEN_OK, $session->sessionId, [ + 'target_host' => $session->targetHost, + 'target_port' => $session->targetPort, + ])); + }; + $target->onMessage = function (AsyncTcpConnection $target, string $data) use ($session, $agentConnection): void { + $session->bytesTargetToClient += strlen($data); + $this->send($agentConnection, new Frame(FrameType::DATA, $session->sessionId, [ + 'data' => base64_encode($data), + ])); + }; + $target->onClose = fn () => $this->closeSession($session, 'closed', null); + $target->onError = fn () => $this->failOpenSession($agentConnection, $session, 'target_connection_refused'); + $target->connect(); + } + + private function forwardDataToTarget(TunnelSession $session, string $encoded): void + { + $data = base64_decode($encoded, true); + if ($data === false) { + $this->closeSession($session, 'failed', 'invalid_frame'); + return; + } + + $session->bytesClientToTarget += strlen($data); + $session->target?->send($data); + } + + private function failOpenSession(TcpConnection $agentConnection, TunnelSession $session, string $reason): void + { + $this->send($agentConnection, new Frame(FrameType::OPEN_FAIL, $session->sessionId, ['reason' => $reason])); + $this->closeSession($session, 'failed', $reason); + } + + private function rejectOpen(TcpConnection $agentConnection, Frame $frame, string $reason, string $userId, string $nodeId, ?string $policyId = null): void + { + $this->send($agentConnection, new Frame(FrameType::OPEN_FAIL, $frame->sessionId, ['reason' => $reason])); + $this->audit->write([ + 'session_id' => $frame->sessionId, + 'user_id' => $userId, + 'source_ip' => (string)($frame->payload['source_ip'] ?? $agentConnection->getRemoteIp() ?? ''), + 'target_host' => (string)($frame->payload['target_host'] ?? ''), + 'target_port' => (int)($frame->payload['target_port'] ?? 0), + 'protocol' => (string)($frame->payload['protocol'] ?? 'tcp'), + 'route_type' => 'reject', + 'node_id' => $nodeId, + 'policy_id' => $policyId, + 'start_time' => date(DATE_ATOM), + 'duration_ms' => 0, + 'bytes_client_to_target' => 0, + 'bytes_target_to_client' => 0, + 'result' => 'failed', + 'failure_reason' => $reason, + ]); + } + + public function closeSession(TunnelSession $session, string $result, ?string $reason): void + { + if ($session->state === TunnelSession::CLOSED) { + return; + } + + $session->state = TunnelSession::CLOSED; + $this->sessions->remove($session->sessionId); + + if ($session->agent !== null) { + $this->send($session->agent, new Frame(FrameType::CLOSE, $session->sessionId, ['reason' => $reason ?? $result])); + } + $session->client?->close(); + $session->target?->close(); + + $endMs = (int)floor(microtime(true) * 1000); + $this->audit->write([ + 'session_id' => $session->sessionId, + 'user_id' => $session->userId, + 'source_ip' => $session->sourceIp, + 'target_host' => $session->targetHost, + 'target_port' => $session->targetPort, + 'protocol' => $session->protocol, + 'route_type' => $session->routeType, + 'node_id' => $session->nodeId, + 'policy_id' => $session->policyId, + 'start_time' => $session->startTime, + 'duration_ms' => $endMs - $session->startedAtMs, + 'bytes_client_to_target' => $session->bytesClientToTarget, + 'bytes_target_to_client' => $session->bytesTargetToClient, + 'result' => $result, + 'failure_reason' => $reason, + ]); + } + + private function onClose(TcpConnection $connection): void + { + unset($this->parsers[$connection->id]); + unset($this->connectionNodeIds[$connection->id]); + $node = $this->nodes->unregisterByConnection($connection); + foreach ($this->sessions->all() as $session) { + if ($session->agent === $connection) { + $this->closeSession($session, 'failed', 'node_offline'); + } + } + if ($node === null) { + return; + } + + echo "Agent offline: {$node->nodeId}\n"; + } + + private function sweepHeartbeats(): void + { + foreach ($this->nodes->all() as $node) { + if (time() - $node->lastHeartbeat > 30) { + $node->connection->close(); + } + } + } + + private function send(TcpConnection $connection, Frame $frame): void + { + $connection->send(FrameCodec::encode($frame)); + } +} diff --git a/src/Server/PopServer.php b/src/Server/PopServer.php new file mode 100644 index 0000000..161e596 --- /dev/null +++ b/src/Server/PopServer.php @@ -0,0 +1,49 @@ +nodes = new NodeRegistry(); + $this->sessions = new SessionManager(); + $this->audit = new AuditLogger($auditLog); + } + + public function boot(): void + { + $agentWorker = new Worker('tcp://' . $this->agentListen); + $agentWorker->name = 'laylink-pop-agent-listener'; + $agentWorker->count = 1; + new AgentListener( + $agentWorker, + new NodeAuthenticator($this->nodeConfig, $this->allowedAgentTransports), + new ClientAuthenticator(), + new RouteResolver(new PolicyChecker($this->policies), $this->nodes), + $this->nodes, + $this->sessions, + $this->audit, + ); + } +} diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php new file mode 100644 index 0000000..6e97ed5 --- /dev/null +++ b/src/Session/SessionManager.php @@ -0,0 +1,36 @@ + */ + private array $sessions = []; + + public function add(TunnelSession $session): void + { + $this->sessions[$session->sessionId] = $session; + } + + public function get(string $sessionId): ?TunnelSession + { + return $this->sessions[$sessionId] ?? null; + } + + public function remove(string $sessionId): ?TunnelSession + { + $session = $this->sessions[$sessionId] ?? null; + unset($this->sessions[$sessionId]); + return $session; + } + + /** + * @return TunnelSession[] + */ + public function all(): array + { + return array_values($this->sessions); + } +} diff --git a/src/Session/TunnelSession.php b/src/Session/TunnelSession.php new file mode 100644 index 0000000..4b044c0 --- /dev/null +++ b/src/Session/TunnelSession.php @@ -0,0 +1,42 @@ +startTime = date(DATE_ATOM); + $this->startedAtMs = (int)floor(microtime(true) * 1000); + } +} diff --git a/src/Util/Env.php b/src/Util/Env.php new file mode 100644 index 0000000..813224c --- /dev/null +++ b/src/Util/Env.php @@ -0,0 +1,67 @@ + strtolower(trim($item)), + explode(',', $value), + ))); + } +} diff --git a/src/Util/Uuid.php b/src/Util/Uuid.php new file mode 100644 index 0000000..86678f5 --- /dev/null +++ b/src/Util/Uuid.php @@ -0,0 +1,17 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0, PSR-4 and classmap class loader. + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + * @see https://www.php-fig.org/psr/psr-0/ + * @see https://www.php-fig.org/psr/psr-4/ + */ +class ClassLoader +{ + /** @var \Closure(string):void */ + private static $includeFile; + + /** @var string|null */ + private $vendorDir; + + // PSR-4 + /** + * @var array> + */ + private $prefixLengthsPsr4 = array(); + /** + * @var array> + */ + private $prefixDirsPsr4 = array(); + /** + * @var list + */ + private $fallbackDirsPsr4 = array(); + + // PSR-0 + /** + * List of PSR-0 prefixes + * + * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2'))) + * + * @var array>> + */ + private $prefixesPsr0 = array(); + /** + * @var list + */ + private $fallbackDirsPsr0 = array(); + + /** @var bool */ + private $useIncludePath = false; + + /** + * @var array + */ + private $classMap = array(); + + /** @var bool */ + private $classMapAuthoritative = false; + + /** + * @var array + */ + private $missingClasses = array(); + + /** @var string|null */ + private $apcuPrefix; + + /** + * @var array + */ + private static $registeredLoaders = array(); + + /** + * @param string|null $vendorDir + */ + public function __construct($vendorDir = null) + { + $this->vendorDir = $vendorDir; + self::initializeIncludeClosure(); + } + + /** + * @return array> + */ + public function getPrefixes() + { + if (!empty($this->prefixesPsr0)) { + return call_user_func_array('array_merge', array_values($this->prefixesPsr0)); + } + + return array(); + } + + /** + * @return array> + */ + public function getPrefixesPsr4() + { + return $this->prefixDirsPsr4; + } + + /** + * @return list + */ + public function getFallbackDirs() + { + return $this->fallbackDirsPsr0; + } + + /** + * @return list + */ + public function getFallbackDirsPsr4() + { + return $this->fallbackDirsPsr4; + } + + /** + * @return array Array of classname => path + */ + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + * + * @return void + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, either + * appending or prepending to the ones previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 root directories + * @param bool $prepend Whether to prepend the directories + * + * @return void + */ + public function add($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + if ($prepend) { + $this->fallbackDirsPsr0 = array_merge( + $paths, + $this->fallbackDirsPsr0 + ); + } else { + $this->fallbackDirsPsr0 = array_merge( + $this->fallbackDirsPsr0, + $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixesPsr0[$first][$prefix])) { + $this->prefixesPsr0[$first][$prefix] = $paths; + + return; + } + if ($prepend) { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $paths, + $this->prefixesPsr0[$first][$prefix] + ); + } else { + $this->prefixesPsr0[$first][$prefix] = array_merge( + $this->prefixesPsr0[$first][$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, either + * appending or prepending to the ones previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * @param bool $prepend Whether to prepend the directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function addPsr4($prefix, $paths, $prepend = false) + { + $paths = (array) $paths; + if (!$prefix) { + // Register directories for the root namespace. + if ($prepend) { + $this->fallbackDirsPsr4 = array_merge( + $paths, + $this->fallbackDirsPsr4 + ); + } else { + $this->fallbackDirsPsr4 = array_merge( + $this->fallbackDirsPsr4, + $paths + ); + } + } elseif (!isset($this->prefixDirsPsr4[$prefix])) { + // Register directories for a new namespace. + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = $paths; + } elseif ($prepend) { + // Prepend directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $paths, + $this->prefixDirsPsr4[$prefix] + ); + } else { + // Append directories for an already registered namespace. + $this->prefixDirsPsr4[$prefix] = array_merge( + $this->prefixDirsPsr4[$prefix], + $paths + ); + } + } + + /** + * Registers a set of PSR-0 directories for a given prefix, + * replacing any others previously set for this prefix. + * + * @param string $prefix The prefix + * @param list|string $paths The PSR-0 base directories + * + * @return void + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr0 = (array) $paths; + } else { + $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths; + } + } + + /** + * Registers a set of PSR-4 directories for a given namespace, + * replacing any others previously set for this namespace. + * + * @param string $prefix The prefix/namespace, with trailing '\\' + * @param list|string $paths The PSR-4 base directories + * + * @throws \InvalidArgumentException + * + * @return void + */ + public function setPsr4($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirsPsr4 = (array) $paths; + } else { + $length = strlen($prefix); + if ('\\' !== $prefix[$length - 1]) { + throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator."); + } + $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length; + $this->prefixDirsPsr4[$prefix] = (array) $paths; + } + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + * + * @return void + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Turns off searching the prefix and fallback directories for classes + * that have not been registered with the class map. + * + * @param bool $classMapAuthoritative + * + * @return void + */ + public function setClassMapAuthoritative($classMapAuthoritative) + { + $this->classMapAuthoritative = $classMapAuthoritative; + } + + /** + * Should class lookup fail if not found in the current class map? + * + * @return bool + */ + public function isClassMapAuthoritative() + { + return $this->classMapAuthoritative; + } + + /** + * APCu prefix to use to cache found/not-found classes, if the extension is enabled. + * + * @param string|null $apcuPrefix + * + * @return void + */ + public function setApcuPrefix($apcuPrefix) + { + $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null; + } + + /** + * The APCu prefix in use, or null if APCu caching is not enabled. + * + * @return string|null + */ + public function getApcuPrefix() + { + return $this->apcuPrefix; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + * + * @return void + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + + if (null === $this->vendorDir) { + return; + } + + if ($prepend) { + self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders; + } else { + unset(self::$registeredLoaders[$this->vendorDir]); + self::$registeredLoaders[$this->vendorDir] = $this; + } + } + + /** + * Unregisters this instance as an autoloader. + * + * @return void + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + + if (null !== $this->vendorDir) { + unset(self::$registeredLoaders[$this->vendorDir]); + } + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return true|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + $includeFile = self::$includeFile; + $includeFile($file); + + return true; + } + + return null; + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // class map lookup + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) { + return false; + } + if (null !== $this->apcuPrefix) { + $file = apcu_fetch($this->apcuPrefix.$class, $hit); + if ($hit) { + return $file; + } + } + + $file = $this->findFileWithExtension($class, '.php'); + + // Search for Hack files if we are running on HHVM + if (false === $file && defined('HHVM_VERSION')) { + $file = $this->findFileWithExtension($class, '.hh'); + } + + if (null !== $this->apcuPrefix) { + apcu_add($this->apcuPrefix.$class, $file); + } + + if (false === $file) { + // Remember that this class does not exist. + $this->missingClasses[$class] = true; + } + + return $file; + } + + /** + * Returns the currently registered loaders keyed by their corresponding vendor directories. + * + * @return array + */ + public static function getRegisteredLoaders() + { + return self::$registeredLoaders; + } + + /** + * @param string $class + * @param string $ext + * @return string|false + */ + private function findFileWithExtension($class, $ext) + { + // PSR-4 lookup + $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext; + + $first = $class[0]; + if (isset($this->prefixLengthsPsr4[$first])) { + $subPath = $class; + while (false !== $lastPos = strrpos($subPath, '\\')) { + $subPath = substr($subPath, 0, $lastPos); + $search = $subPath . '\\'; + if (isset($this->prefixDirsPsr4[$search])) { + $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1); + foreach ($this->prefixDirsPsr4[$search] as $dir) { + if (file_exists($file = $dir . $pathEnd)) { + return $file; + } + } + } + } + } + + // PSR-4 fallback dirs + foreach ($this->fallbackDirsPsr4 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) { + return $file; + } + } + + // PSR-0 lookup + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1) + . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR); + } else { + // PEAR-like class name + $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext; + } + + if (isset($this->prefixesPsr0[$first])) { + foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + } + } + } + + // PSR-0 fallback dirs + foreach ($this->fallbackDirsPsr0 as $dir) { + if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) { + return $file; + } + } + + // PSR-0 include paths. + if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) { + return $file; + } + + return false; + } + + /** + * @return void + */ + private static function initializeIncludeClosure() + { + if (self::$includeFile !== null) { + return; + } + + /** + * Scope isolated include. + * + * Prevents access to $this/self from included files. + * + * @param string $file + * @return void + */ + self::$includeFile = \Closure::bind(static function($file) { + include $file; + }, null, null); + } +} diff --git a/vendor/composer/InstalledVersions.php b/vendor/composer/InstalledVersions.php new file mode 100644 index 0000000..2052022 --- /dev/null +++ b/vendor/composer/InstalledVersions.php @@ -0,0 +1,396 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +use Composer\Autoload\ClassLoader; +use Composer\Semver\VersionParser; + +/** + * This class is copied in every Composer installed project and available to all + * + * See also https://getcomposer.org/doc/07-runtime.md#installed-versions + * + * To require its presence, you can require `composer-runtime-api ^2.0` + * + * @final + */ +class InstalledVersions +{ + /** + * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to + * @internal + */ + private static $selfDir = null; + + /** + * @var mixed[]|null + * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null + */ + private static $installed; + + /** + * @var bool + */ + private static $installedIsLocalDir; + + /** + * @var bool|null + */ + private static $canGetVendors; + + /** + * @var array[] + * @psalm-var array}> + */ + private static $installedByVendor = array(); + + /** + * Returns a list of all package names which are present, either by being installed, replaced or provided + * + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackages() + { + $packages = array(); + foreach (self::getInstalled() as $installed) { + $packages[] = array_keys($installed['versions']); + } + + if (1 === \count($packages)) { + return $packages[0]; + } + + return array_keys(array_flip(\call_user_func_array('array_merge', $packages))); + } + + /** + * Returns a list of all package names with a specific type e.g. 'library' + * + * @param string $type + * @return string[] + * @psalm-return list + */ + public static function getInstalledPackagesByType($type) + { + $packagesByType = array(); + + foreach (self::getInstalled() as $installed) { + foreach ($installed['versions'] as $name => $package) { + if (isset($package['type']) && $package['type'] === $type) { + $packagesByType[] = $name; + } + } + } + + return $packagesByType; + } + + /** + * Checks whether the given package is installed + * + * This also returns true if the package name is provided or replaced by another package + * + * @param string $packageName + * @param bool $includeDevRequirements + * @return bool + */ + public static function isInstalled($packageName, $includeDevRequirements = true) + { + foreach (self::getInstalled() as $installed) { + if (isset($installed['versions'][$packageName])) { + return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false; + } + } + + return false; + } + + /** + * Checks whether the given package satisfies a version constraint + * + * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call: + * + * Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3') + * + * @param VersionParser $parser Install composer/semver to have access to this class and functionality + * @param string $packageName + * @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package + * @return bool + */ + public static function satisfies(VersionParser $parser, $packageName, $constraint) + { + $constraint = $parser->parseConstraints((string) $constraint); + $provided = $parser->parseConstraints(self::getVersionRanges($packageName)); + + return $provided->matches($constraint); + } + + /** + * Returns a version constraint representing all the range(s) which are installed for a given package + * + * It is easier to use this via isInstalled() with the $constraint argument if you need to check + * whether a given version of a package is installed, and not just whether it exists + * + * @param string $packageName + * @return string Version constraint usable with composer/semver + */ + public static function getVersionRanges($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + $ranges = array(); + if (isset($installed['versions'][$packageName]['pretty_version'])) { + $ranges[] = $installed['versions'][$packageName]['pretty_version']; + } + if (array_key_exists('aliases', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']); + } + if (array_key_exists('replaced', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']); + } + if (array_key_exists('provided', $installed['versions'][$packageName])) { + $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']); + } + + return implode(' || ', $ranges); + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['version'])) { + return null; + } + + return $installed['versions'][$packageName]['version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present + */ + public static function getPrettyVersion($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['pretty_version'])) { + return null; + } + + return $installed['versions'][$packageName]['pretty_version']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference + */ + public static function getReference($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + if (!isset($installed['versions'][$packageName]['reference'])) { + return null; + } + + return $installed['versions'][$packageName]['reference']; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @param string $packageName + * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path. + */ + public static function getInstallPath($packageName) + { + foreach (self::getInstalled() as $installed) { + if (!isset($installed['versions'][$packageName])) { + continue; + } + + return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null; + } + + throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed'); + } + + /** + * @return array + * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool} + */ + public static function getRootPackage() + { + $installed = self::getInstalled(); + + return $installed[0]['root']; + } + + /** + * Returns the raw installed.php data for custom implementations + * + * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect. + * @return array[] + * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} + */ + public static function getRawData() + { + @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED); + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + self::$installed = include __DIR__ . '/installed.php'; + } else { + self::$installed = array(); + } + } + + return self::$installed; + } + + /** + * Returns the raw data of all installed.php which are currently loaded for custom implementations + * + * @return array[] + * @psalm-return list}> + */ + public static function getAllRawData() + { + return self::getInstalled(); + } + + /** + * Lets you reload the static array from another file + * + * This is only useful for complex integrations in which a project needs to use + * this class but then also needs to execute another project's autoloader in process, + * and wants to ensure both projects have access to their version of installed.php. + * + * A typical case would be PHPUnit, where it would need to make sure it reads all + * the data it needs from this class, then call reload() with + * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure + * the project in which it runs can then also use this class safely, without + * interference between PHPUnit's dependencies and the project's dependencies. + * + * @param array[] $data A vendor/composer/installed.php data set + * @return void + * + * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $data + */ + public static function reload($data) + { + self::$installed = $data; + self::$installedByVendor = array(); + + // when using reload, we disable the duplicate protection to ensure that self::$installed data is + // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not, + // so we have to assume it does not, and that may result in duplicate data being returned when listing + // all installed packages for example + self::$installedIsLocalDir = false; + } + + /** + * @return string + */ + private static function getSelfDir() + { + if (self::$selfDir === null) { + self::$selfDir = strtr(__DIR__, '\\', '/'); + } + + return self::$selfDir; + } + + /** + * @return array[] + * @psalm-return list}> + */ + private static function getInstalled() + { + if (null === self::$canGetVendors) { + self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders'); + } + + $installed = array(); + $copiedLocalDir = false; + + if (self::$canGetVendors) { + $selfDir = self::getSelfDir(); + foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + $vendorDir = strtr($vendorDir, '\\', '/'); + if (isset(self::$installedByVendor[$vendorDir])) { + $installed[] = self::$installedByVendor[$vendorDir]; + } elseif (is_file($vendorDir.'/composer/installed.php')) { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require $vendorDir.'/composer/installed.php'; + self::$installedByVendor[$vendorDir] = $required; + $installed[] = $required; + if (self::$installed === null && $vendorDir.'/composer' === $selfDir) { + self::$installed = $required; + self::$installedIsLocalDir = true; + } + } + if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) { + $copiedLocalDir = true; + } + } + } + + if (null === self::$installed) { + // only require the installed.php file if this file is loaded from its dumped location, + // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937 + if (substr(__DIR__, -8, 1) !== 'C') { + /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ + $required = require __DIR__ . '/installed.php'; + self::$installed = $required; + } else { + self::$installed = array(); + } + } + + if (self::$installed !== array() && !$copiedLocalDir) { + $installed[] = self::$installed; + } + + return $installed; + } +} diff --git a/vendor/composer/LICENSE b/vendor/composer/LICENSE new file mode 100644 index 0000000..f27399a --- /dev/null +++ b/vendor/composer/LICENSE @@ -0,0 +1,21 @@ + +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000..0fb0a2c --- /dev/null +++ b/vendor/composer/autoload_classmap.php @@ -0,0 +1,10 @@ + $vendorDir . '/composer/InstalledVersions.php', +); diff --git a/vendor/composer/autoload_namespaces.php b/vendor/composer/autoload_namespaces.php new file mode 100644 index 0000000..15a2ff3 --- /dev/null +++ b/vendor/composer/autoload_namespaces.php @@ -0,0 +1,9 @@ + array($vendorDir . '/workerman/coroutine/src'), + 'Workerman\\' => array($vendorDir . '/workerman/workerman/src', $vendorDir . '/workerman/coroutine/src'), + 'LayLink\\' => array($baseDir . '/src'), +); diff --git a/vendor/composer/autoload_real.php b/vendor/composer/autoload_real.php new file mode 100644 index 0000000..9e93e37 --- /dev/null +++ b/vendor/composer/autoload_real.php @@ -0,0 +1,38 @@ +register(true); + + return $loader; + } +} diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php new file mode 100644 index 0000000..e5de083 --- /dev/null +++ b/vendor/composer/autoload_static.php @@ -0,0 +1,50 @@ + + array ( + 'Workerman\\Coroutine\\' => 20, + 'Workerman\\' => 10, + ), + 'L' => + array ( + 'LayLink\\' => 8, + ), + ); + + public static $prefixDirsPsr4 = array ( + 'Workerman\\Coroutine\\' => + array ( + 0 => __DIR__ . '/..' . '/workerman/coroutine/src', + ), + 'Workerman\\' => + array ( + 0 => __DIR__ . '/..' . '/workerman/workerman/src', + 1 => __DIR__ . '/..' . '/workerman/coroutine/src', + ), + 'LayLink\\' => + array ( + 0 => __DIR__ . '/../..' . '/src', + ), + ); + + public static $classMap = array ( + 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', + ); + + public static function getInitializer(ClassLoader $loader) + { + return \Closure::bind(function () use ($loader) { + $loader->prefixLengthsPsr4 = ComposerStaticInit54847d6030d29731b0e767d050d22a36::$prefixLengthsPsr4; + $loader->prefixDirsPsr4 = ComposerStaticInit54847d6030d29731b0e767d050d22a36::$prefixDirsPsr4; + $loader->classMap = ComposerStaticInit54847d6030d29731b0e767d050d22a36::$classMap; + + }, null, ClassLoader::class); + } +} diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json new file mode 100644 index 0000000..c0a72f5 --- /dev/null +++ b/vendor/composer/installed.json @@ -0,0 +1,128 @@ +{ + "packages": [ + { + "name": "workerman/coroutine", + "version": "v1.1.5", + "version_normalized": "1.1.5.0", + "source": { + "type": "git", + "url": "https://github.com/workerman-php/coroutine.git", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/workerman-php/coroutine/zipball/b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "workerman/workerman": "^5.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "psr/log": "*" + }, + "time": "2026-03-12T02:07:37+00:00", + "type": "library", + "installation-source": "dist", + "autoload": { + "psr-4": { + "Workerman\\": "src", + "Workerman\\Coroutine\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Workerman coroutine", + "support": { + "issues": "https://github.com/workerman-php/coroutine/issues", + "source": "https://github.com/workerman-php/coroutine/tree/v1.1.5" + }, + "install-path": "../workerman/coroutine" + }, + { + "name": "workerman/workerman", + "version": "v5.2.0", + "version_normalized": "5.2.0.0", + "source": { + "type": "git", + "url": "https://github.com/walkor/workerman.git", + "reference": "1d8694c945bc64a5bc11ad753ec7220bcba37cb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/walkor/workerman/zipball/1d8694c945bc64a5bc11ad753ec7220bcba37cb1", + "reference": "1d8694c945bc64a5bc11ad753ec7220bcba37cb1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "workerman/coroutine": "^1.1 || dev-main" + }, + "conflict": { + "ext-swow": " array( + 'name' => '__root__', + 'pretty_version' => '1.0.0+no-version-set', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev' => true, + ), + 'versions' => array( + '__root__' => array( + 'pretty_version' => '1.0.0+no-version-set', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'workerman/coroutine' => array( + 'pretty_version' => 'v1.1.5', + 'version' => '1.1.5.0', + 'reference' => 'b60e44267b90d398dbfa7a320f3e97b46357ac9f', + 'type' => 'library', + 'install_path' => __DIR__ . '/../workerman/coroutine', + 'aliases' => array(), + 'dev_requirement' => false, + ), + 'workerman/workerman' => array( + 'pretty_version' => 'v5.2.0', + 'version' => '5.2.0.0', + 'reference' => '1d8694c945bc64a5bc11ad753ec7220bcba37cb1', + 'type' => 'library', + 'install_path' => __DIR__ . '/../workerman/workerman', + 'aliases' => array(), + 'dev_requirement' => false, + ), + ), +); diff --git a/vendor/composer/platform_check.php b/vendor/composer/platform_check.php new file mode 100644 index 0000000..2beb149 --- /dev/null +++ b/vendor/composer/platform_check.php @@ -0,0 +1,25 @@ += 80100)) { + $issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.'; +} + +if ($issues) { + if (!headers_sent()) { + header('HTTP/1.1 500 Internal Server Error'); + } + if (!ini_get('display_errors')) { + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL); + } elseif (!headers_sent()) { + echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; + } + } + throw new \RuntimeException( + 'Composer detected issues in your platform: ' . implode(' ', $issues) + ); +} diff --git a/vendor/workerman/coroutine/.gitignore b/vendor/workerman/coroutine/.gitignore new file mode 100644 index 0000000..ffd79ff --- /dev/null +++ b/vendor/workerman/coroutine/.gitignore @@ -0,0 +1,5 @@ +composer.lock +vendor +.idea +tests/.phpunit.result.cache +tests/workerman.log \ No newline at end of file diff --git a/vendor/workerman/coroutine/LICENSE b/vendor/workerman/coroutine/LICENSE new file mode 100644 index 0000000..815fff2 --- /dev/null +++ b/vendor/workerman/coroutine/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 workerman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/workerman/coroutine/README.md b/vendor/workerman/coroutine/README.md new file mode 100644 index 0000000..a66d110 --- /dev/null +++ b/vendor/workerman/coroutine/README.md @@ -0,0 +1,3 @@ +# Workerman coroutine library + +This is Workerman's coroutine library, which includes `Coroutine` `Channel` `Barrier` `Parallel` `Pool`. \ No newline at end of file diff --git a/vendor/workerman/coroutine/composer.json b/vendor/workerman/coroutine/composer.json new file mode 100644 index 0000000..d7fe9f6 --- /dev/null +++ b/vendor/workerman/coroutine/composer.json @@ -0,0 +1,32 @@ +{ + "name": "workerman/coroutine", + "type": "library", + "license": "MIT", + "description": "Workerman coroutine", + "require": { + "php": ">=8.1", + "workerman/workerman": "^5.1" + }, + "autoload": { + "psr-4": { + "Workerman\\Coroutine\\": "src", + "Workerman\\": "src" + } + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "psr/log": "*" + }, + "autoload-dev": { + "psr-4": { + "Workerman\\Coroutine\\": "src", + "Workerman\\": "src", + "tests\\": "tests" + } + }, + "scripts": { + "test": "php tests/start.php start" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/vendor/workerman/coroutine/src/Barrier.php b/vendor/workerman/coroutine/src/Barrier.php new file mode 100644 index 0000000..fc9e58a --- /dev/null +++ b/vendor/workerman/coroutine/src/Barrier.php @@ -0,0 +1,65 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine; + +use Workerman\Coroutine\Barrier\BarrierInterface; +use Workerman\Events\Swoole; +use Workerman\Events\Swow; +use Workerman\Worker; + +/** + * Class Barrier + */ +class Barrier implements BarrierInterface +{ + + /** + * @var string + */ + protected static string $driver; + + /** + * Get driver. + * + * @return string + */ + protected static function getDriver(): string + { + return static::$driver ??= match (Worker::$eventLoopClass) { + Swoole::class => Barrier\Swoole::class, + Swow::class => Barrier\Swow::class, + default=> Barrier\Fiber::class, + }; + } + + /** + * @inheritDoc + */ + public static function wait(object &$barrier, int $timeout = -1): void + { + static::getDriver()::wait($barrier, $timeout); + } + + /** + * @inheritDoc + */ + public static function create(): object + { + return static::getDriver()::create(); + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Barrier/BarrierInterface.php b/vendor/workerman/coroutine/src/Barrier/BarrierInterface.php new file mode 100644 index 0000000..e36067e --- /dev/null +++ b/vendor/workerman/coroutine/src/Barrier/BarrierInterface.php @@ -0,0 +1,39 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Barrier; + +/** + * Interface BarrierInterface + */ +interface BarrierInterface +{ + /** + * Wait for the barrier to be released. + * + * @param object $barrier + * @param int $timeout + * @return void + */ + public static function wait(object &$barrier, int $timeout = -1): void; + + /** + * Create a new barrier instance. + * + * @return BarrierInterface + */ + public static function create(): object; +} diff --git a/vendor/workerman/coroutine/src/Barrier/Fiber.php b/vendor/workerman/coroutine/src/Barrier/Fiber.php new file mode 100644 index 0000000..8af3d11 --- /dev/null +++ b/vendor/workerman/coroutine/src/Barrier/Fiber.php @@ -0,0 +1,85 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Barrier; + +use Revolt\EventLoop; +use RuntimeException; +use Workerman\Coroutine\Utils\DestructionWatcher; +use Workerman\Timer; +use Fiber as BaseFiber; +use Workerman\Worker; + +/** + * Class Fiber + */ +class Fiber implements BarrierInterface +{ + + /** + * @inheritDoc + */ + public static function wait(object &$barrier, int $timeout = -1): void + { + $coroutine = BaseFiber::getCurrent(); + $resumed = false; + $timerId = null; + + if ($timeout > 0 && $coroutine) { + $timerId = Timer::delay($timeout, function() use ($coroutine, &$resumed) { + if (!$resumed) { + $resumed = true; + $coroutine->resume(); + } + }); + } + + $coroutine && DestructionWatcher::watch($barrier, function() use ($coroutine, &$resumed, &$timerId) { + if (!$resumed) { + $resumed = true; + if ($timerId !== null) { + Timer::del($timerId); + } + // In PHP 8.4.0 and earlier, + // switching fibers during the execution of an object's destructor method is not allowed, + // so we implemented a delay. + if ($coroutine instanceof BaseFiber) { + Timer::delay(0.00001, function() use ($coroutine) { + $coroutine->resume(); + }); + return; + } + EventLoop::defer(function () use ($coroutine) { + $coroutine->resume(); + }); + } + }); + $barrier = null; + $coroutine && BaseFiber::suspend(); + } + + /** + * @inheritDoc + */ + public static function create(): object + { + if (!Worker::isRunning()) { + throw new RuntimeException('Fiber barrier only support in workerman runtime'); + } + return new self(); + } + +} diff --git a/vendor/workerman/coroutine/src/Barrier/Swoole.php b/vendor/workerman/coroutine/src/Barrier/Swoole.php new file mode 100644 index 0000000..2777f93 --- /dev/null +++ b/vendor/workerman/coroutine/src/Barrier/Swoole.php @@ -0,0 +1,39 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Barrier; + +use Swoole\Coroutine\Barrier as SwooleBarrier; +class Swoole implements BarrierInterface +{ + + /** + * @inheritDoc + */ + public static function wait(object &$barrier, int $timeout = -1): void + { + SwooleBarrier::wait($barrier, $timeout); + } + + /** + * @inheritDoc + */ + public static function create(): object + { + return SwooleBarrier::make(); + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Barrier/Swow.php b/vendor/workerman/coroutine/src/Barrier/Swow.php new file mode 100644 index 0000000..87e44cc --- /dev/null +++ b/vendor/workerman/coroutine/src/Barrier/Swow.php @@ -0,0 +1,39 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Barrier; + +use Swow\Sync\WaitReference; + +class Swow implements BarrierInterface +{ + /** + * @inheritDoc + */ + public static function wait(object &$barrier, int $timeout = -1): void + { + WaitReference::wait($barrier, $timeout); + } + + /** + * @inheritDoc + */ + public static function create(): object + { + return new WaitReference(); + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Channel.php b/vendor/workerman/coroutine/src/Channel.php new file mode 100644 index 0000000..da721a4 --- /dev/null +++ b/vendor/workerman/coroutine/src/Channel.php @@ -0,0 +1,114 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine; + +use InvalidArgumentException; +use Workerman\Coroutine\Channel\ChannelInterface; +use Workerman\Coroutine\Channel\Memory as ChannelMemory; +use Workerman\Coroutine\Channel\Swoole as ChannelSwoole; +use Workerman\Coroutine\Channel\Swow as ChannelSwow; +use Workerman\Coroutine\Channel\Fiber as ChannelFiber; +use Workerman\Events\Fiber; +use Workerman\Events\Swoole; +use Workerman\Events\Swow; +use Workerman\Worker; + +/** + * Class Channel + */ +class Channel implements ChannelInterface +{ + + /** + * @var ChannelInterface + */ + protected ChannelInterface $driver; + + /** + * Channel constructor. + * + * @param int $capacity + */ + public function __construct(int $capacity = 1) + { + if ($capacity < 1) { + throw new InvalidArgumentException("The capacity must be greater than 0"); + } + $this->driver = match (Worker::$eventLoopClass) { + Swoole::class => new ChannelSwoole($capacity), + Swow::class => new ChannelSwow($capacity), + Fiber::class => new ChannelFiber($capacity), + default => new ChannelMemory($capacity), + }; + } + + /** + * @inheritDoc + */ + public function push(mixed $data, float $timeout = -1): bool + { + return $this->driver->push($data, $timeout); + } + + /** + * @inheritDoc + */ + public function pop(float $timeout = -1): mixed + { + return $this->driver->pop($timeout); + } + + /** + * @inheritDoc + */ + public function length(): int + { + return $this->driver->length(); + } + + /** + * @inheritDoc + */ + public function getCapacity(): int + { + return $this->driver->getCapacity(); + } + + /** + * @inheritDoc + */ + public function hasConsumers(): bool + { + return $this->driver->hasConsumers(); + } + + /** + * @inheritDoc + */ + public function hasProducers(): bool + { + return $this->driver->hasProducers(); + } + + /** + * @inheritDoc + */ + public function close(): void + { + $this->driver->close(); + } +} diff --git a/vendor/workerman/coroutine/src/Channel/ChannelInterface.php b/vendor/workerman/coroutine/src/Channel/ChannelInterface.php new file mode 100644 index 0000000..b3b1a6e --- /dev/null +++ b/vendor/workerman/coroutine/src/Channel/ChannelInterface.php @@ -0,0 +1,76 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Channel; + +/** + * ChannelInterface + */ +interface ChannelInterface +{ + /** + * Push data to channel. + * + * @param mixed $data + * @param float $timeout + * @return bool + */ + public function push(mixed $data, float $timeout = -1): bool; + + /** + * Pop data from channel. + * + * @param float $timeout + * @return mixed + */ + public function pop(float $timeout = -1): mixed; + + /** + * Get the length of channel. + * + * @return int + */ + public function length(): int; + + /** + * Get the capacity of channel. + * + * @return int + */ + public function getCapacity(): int; + + /** + * Check if there are consumers waiting to pop data from the channel. + * + * @return bool + */ + public function hasConsumers(): bool; + + /** + * Check if there are producers waiting to push data to the channel. + * + * @return bool + */ + public function hasProducers(): bool; + + /** + * Close the channel. + * + * @return void + */ + public function close(): void; + +} diff --git a/vendor/workerman/coroutine/src/Channel/Fiber.php b/vendor/workerman/coroutine/src/Channel/Fiber.php new file mode 100644 index 0000000..32adacb --- /dev/null +++ b/vendor/workerman/coroutine/src/Channel/Fiber.php @@ -0,0 +1,252 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Channel; + +use Fiber as BaseFiber; +use RuntimeException; +use Workerman\Timer; +use WeakMap; +use Workerman\Worker; + +/** + * Channel + */ +class Fiber implements ChannelInterface +{ + /** + * @var array + */ + private array $queue = []; + + /** + * @var WeakMap + */ + private WeakMap $waitingPush; + + /** + * @var WeakMap + */ + private WeakMap $waitingPop; + + /** + * @var int + */ + private int $capacity; + + /** + * @var bool + */ + private bool $closed = false; + + /** + * Constructor + * + * @param int $capacity + */ + public function __construct(int $capacity = 1) + { + $this->capacity = $capacity; + $this->waitingPush = new WeakMap(); + $this->waitingPop = new WeakMap(); + } + + /** + * @inheritDoc + */ + public function push(mixed $data, float $timeout = -1): bool + { + if ($this->closed) { + return false; + } + + if (count($this->queue) >= $this->capacity) { + + if ($timeout == 0) { + return false; + } + + $fiber = BaseFiber::getCurrent(); + if ($fiber === null) { + throw new RuntimeException("Fiber::getCurrent() returned null. Ensure this method is called within a Fiber context."); + } + + $this->waitingPush[$fiber] = true; + + $timedOut = false; + $timerId = null; + if ($timeout > 0 && Worker::isRunning()) { + $timerId = Timer::delay($timeout, function () use ($fiber, &$timedOut) { + $timedOut = true; + if ($fiber->isSuspended()) { + unset($this->waitingPush[$fiber]); + $fiber->resume(false); + } + }); + } + + BaseFiber::suspend(); + unset($this->waitingPush[$fiber]); + + if (!$timedOut && $timerId) { + Timer::del($timerId); + } + + if ($timedOut) { + return false; + } + + // If the channel is closed while waiting, return false. + if ($this->closed) { + return false; + } + + } + + foreach ($this->waitingPop as $popFiber => $_) { + unset($this->waitingPop[$popFiber]); + if ($popFiber->isSuspended()) { + $popFiber->resume($data); + return true; + } + } + + $this->queue[] = $data; + return true; + } + + /** + * @inheritDoc + */ + public function pop(float $timeout = -1): mixed + { + if ($this->closed && empty($this->queue)) { + return false; + } + + if (empty($this->queue)) { + if ($timeout == 0) { + return false; + } + + $fiber = BaseFiber::getCurrent(); + if ($fiber === null) { + throw new RuntimeException("Fiber::getCurrent() returned null. Ensure this method is called within a Fiber context."); + } + + $this->waitingPop[$fiber] = true; + + $timedOut = false; + $timerId = null; + if ($timeout > 0) { + Worker::isRunning() && $timerId = Timer::delay($timeout, function () use ($fiber, &$timedOut) { + $timedOut = true; + if ($fiber->isSuspended()) { + unset($this->waitingPop[$fiber]); + $fiber->resume(false); + } + }); + } + + $data = BaseFiber::suspend(); + + unset($this->waitingPop[$fiber]); + + if (!$timedOut && $timerId !== null) { + Timer::del($timerId); + } + + if ($timedOut) { + return false; + } + + if ($data === false && $this->closed) { + return false; + } + + return $data; + } + + $value = array_shift($this->queue); + + foreach ($this->waitingPush as $pushFiber => $_) { + unset($this->waitingPush[$pushFiber]); + if ($pushFiber->isSuspended()) { + $pushFiber->resume(); + break; + } + } + + return $value; + } + + /** + * @inheritDoc + */ + public function length(): int + { + return count($this->queue); + } + + /** + * @inheritDoc + */ + public function getCapacity(): int + { + return $this->capacity; + } + + /** + * @inheritDoc + */ + public function hasConsumers(): bool + { + return count($this->waitingPop) > 0; + } + + /** + * @inheritDoc + */ + public function hasProducers(): bool + { + return count($this->waitingPush) > 0; + } + + /** + * @inheritDoc + */ + public function close(): void + { + $this->closed = true; + + foreach ($this->waitingPush as $fiber => $_) { + unset($this->waitingPush[$fiber]); + if ($fiber->isSuspended()) { + $fiber->resume(false); + } + } + $this->waitingPush = new WeakMap(); + + foreach ($this->waitingPop as $fiber => $_) { + unset($this->waitingPop[$fiber]); + if ($fiber->isSuspended()) { + $fiber->resume(false); + } + } + $this->waitingPop = new WeakMap(); + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Channel/Memory.php b/vendor/workerman/coroutine/src/Channel/Memory.php new file mode 100644 index 0000000..20b7fe8 --- /dev/null +++ b/vendor/workerman/coroutine/src/Channel/Memory.php @@ -0,0 +1,69 @@ +capacity = $capacity; + } + + public function push(mixed $data, float $timeout = -1): bool + { + if ($this->closed) { + return false; + } + if ($this->capacity > 0 && count($this->data) >= $this->capacity) { + // Channel is full + return false; + } + $this->data[] = $data; + return true; + } + + public function pop(float $timeout = -1): mixed + { + if (count($this->data) > 0) { + return array_shift($this->data); + } + return false; + } + + public function length(): int + { + return count($this->data); + } + + public function getCapacity(): int + { + return $this->capacity; + } + + /** + * @inheritDoc + */ + public function hasConsumers(): bool + { + return false; + } + + /** + * @inheritDoc + */ + public function hasProducers(): bool + { + return false; + } + + public function close(): void + { + $this->closed = true; + } +} diff --git a/vendor/workerman/coroutine/src/Channel/Swoole.php b/vendor/workerman/coroutine/src/Channel/Swoole.php new file mode 100644 index 0000000..71c33d1 --- /dev/null +++ b/vendor/workerman/coroutine/src/Channel/Swoole.php @@ -0,0 +1,98 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Channel; + +use Swoole\Coroutine\Channel; + +/** + * Class Swoole + */ +class Swoole implements ChannelInterface +{ + + /** + * @var Channel + */ + protected Channel $channel; + + /** + * Constructor. + * + * @param int $capacity + */ + public function __construct(protected int $capacity = 1) + { + $this->channel = new Channel($capacity); + } + + /** + * @inheritDoc + */ + public function push(mixed $data, float $timeout = -1): bool + { + return $this->channel->push($data, $timeout); + } + + /** + * @inheritDoc + */ + public function pop(float $timeout = -1): mixed + { + return $this->channel->pop($timeout); + } + + /** + * @inheritDoc + */ + public function length(): int + { + return $this->channel->length(); + } + + /** + * @inheritDoc + */ + public function getCapacity(): int + { + return $this->channel->capacity; + } + + /** + * @inheritDoc + */ + public function hasConsumers(): bool + { + return $this->channel->stats()['consumer_num'] > 0; + } + + /** + * @inheritDoc + */ + public function hasProducers(): bool + { + return $this->channel->stats()['producer_num'] > 0; + } + + /** + * @inheritDoc + */ + public function close(): void + { + $this->channel->close(); + } + +} diff --git a/vendor/workerman/coroutine/src/Channel/Swow.php b/vendor/workerman/coroutine/src/Channel/Swow.php new file mode 100644 index 0000000..ae20670 --- /dev/null +++ b/vendor/workerman/coroutine/src/Channel/Swow.php @@ -0,0 +1,108 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Channel; + +use Swow\Channel; +use Throwable; + +/** + * Class Swow + */ +class Swow implements ChannelInterface +{ + + /** + * @var Channel + */ + protected Channel $channel; + + /** + * Constructor. + * + * @param int $capacity + */ + public function __construct(protected int $capacity = 1) + { + $this->channel = new Channel($capacity); + } + + /** + * @inheritDoc + */ + public function push(mixed $data, float $timeout = -1): bool + { + try { + $this->channel->push($data, $timeout == -1 ? -1 : (int)($timeout * 1000)); + } catch (Throwable) { + return false; + } + return true; + } + + /** + * @inheritDoc + */ + public function pop(float $timeout = -1): mixed + { + try { + return $this->channel->pop($timeout == -1 ? -1 : (int)($timeout * 1000)); + } catch (Throwable) { + return false; + } + } + + /** + * @inheritDoc + */ + public function length(): int + { + return $this->channel->getLength(); + } + + /** + * @inheritDoc + */ + public function getCapacity(): int + { + return $this->channel->getCapacity(); + } + + /** + * @inheritDoc + */ + public function hasConsumers(): bool + { + return $this->channel->hasConsumers(); + } + + /** + * @inheritDoc + */ + public function hasProducers(): bool + { + return $this->channel->hasProducers(); + } + + /** + * @inheritDoc + */ + public function close(): void + { + $this->channel->close(); + } + +} diff --git a/vendor/workerman/coroutine/src/Context.php b/vendor/workerman/coroutine/src/Context.php new file mode 100644 index 0000000..48a073c --- /dev/null +++ b/vendor/workerman/coroutine/src/Context.php @@ -0,0 +1,90 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine; + +use ArrayObject; +use Workerman\Coroutine\Context\ContextInterface; +use Workerman\Events\Swoole; +use Workerman\Events\Swow; +use Workerman\Worker; + +/** + * Class Context + */ +class Context implements ContextInterface +{ + + /** + * @var class-string + */ + protected static string $driver; + + /** + * @inheritDoc + */ + public static function get(?string $name = null, mixed $default = null): mixed + { + return static::$driver::get($name, $default); + } + + /** + * @inheritDoc + */ + public static function set(string $name, $value): void + { + static::$driver::set($name, $value); + } + + /** + * @inheritDoc + */ + public static function has(string $name): bool + { + return static::$driver::has($name); + } + + /** + * @inheritDoc + */ + public static function reset(?ArrayObject $data = null): void + { + static::$driver::reset($data); + } + + /** + * @inheritDoc + */ + public static function destroy(): void + { + static::$driver::destroy(); + } + + /** + * @return void + */ + public static function initDriver(): void + { + static::$driver ??= match (Worker::$eventLoopClass) { + Swoole::class => Context\Swoole::class, + Swow::class => Context\Swow::class, + default=> Context\Fiber::class, + }; + } + +} + +Context::initDriver(); \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Context/ContextInterface.php b/vendor/workerman/coroutine/src/Context/ContextInterface.php new file mode 100644 index 0000000..4920608 --- /dev/null +++ b/vendor/workerman/coroutine/src/Context/ContextInterface.php @@ -0,0 +1,50 @@ +offsetExists($name); + } + return isset(static::$contexts[$fiber]) && static::$contexts[$fiber]->offsetExists($name); + } + + /** + * @inheritDoc + */ + public static function reset(?ArrayObject $data = null): void + { + if ($data) { + $data->setFlags(ArrayObject::ARRAY_AS_PROPS); + } else { + $data = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS); + } + $fiber = BaseFiber::getCurrent(); + if ($fiber === null) { + static::$nonFiberContext = $data; + return; + } + static::$contexts[$fiber] = $data; + } + + /** + * @inheritDoc + */ + public static function destroy(): void + { + $fiber = BaseFiber::getCurrent(); + if ($fiber === null) { + static::$nonFiberContext = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS); + return; + } + unset(static::$contexts[$fiber]); + } + + /** + * Initialize the weakMap. + */ + public static function initContext(): void + { + static::$contexts = new WeakMap(); + static::$nonFiberContext = new ArrayObject([], ArrayObject::ARRAY_AS_PROPS); + } + +} + +Fiber::initContext(); \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Context/Swoole.php b/vendor/workerman/coroutine/src/Context/Swoole.php new file mode 100644 index 0000000..ceb4e62 --- /dev/null +++ b/vendor/workerman/coroutine/src/Context/Swoole.php @@ -0,0 +1,63 @@ +setFlags(ArrayObject::ARRAY_AS_PROPS); + if ($name === null) { + return $context; + } + return $context[$name] ?? $default; + } + + /** + * @inheritDoc + */ + public static function set(string $name, $value): void + { + Coroutine::getContext()[$name] = $value; + } + + /** + * @inheritDoc + */ + public static function has(string $name): bool + { + $context = Coroutine::getContext(); + return $context->offsetExists($name); + } + + /** + * @inheritDoc + */ + public static function reset(?ArrayObject $data = null): void + { + $context = Coroutine::getContext(); + $context->setFlags(ArrayObject::ARRAY_AS_PROPS); + $context->exchangeArray($data ? $data->getArrayCopy() : []); + } + + /** + * @inheritDoc + */ + public static function destroy(): void + { + $context = Coroutine::getContext(); + $context->exchangeArray([]); + } + +} diff --git a/vendor/workerman/coroutine/src/Context/Swow.php b/vendor/workerman/coroutine/src/Context/Swow.php new file mode 100644 index 0000000..74d7737 --- /dev/null +++ b/vendor/workerman/coroutine/src/Context/Swow.php @@ -0,0 +1,78 @@ +offsetExists($name); + } + + /** + * @inheritDoc + */ + public static function reset(?ArrayObject $data = null): void + { + $coroutine = Coroutine::getCurrent(); + $data->setFlags(ArrayObject::ARRAY_AS_PROPS); + static::$contexts[$coroutine] = $data; + } + + /** + * @inheritDoc + */ + public static function destroy(): void + { + unset(static::$contexts[Coroutine::getCurrent()]); + } + + /** + * Initialize the weakMap. + * + * @return void + */ + public static function initContext(): void + { + self::$contexts = new WeakMap(); + } + +} + +Swow::initContext(); \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Coroutine.php b/vendor/workerman/coroutine/src/Coroutine.php new file mode 100644 index 0000000..529da21 --- /dev/null +++ b/vendor/workerman/coroutine/src/Coroutine.php @@ -0,0 +1,129 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman; + +use Workerman\Coroutine\Coroutine\CoroutineInterface; +use Workerman\Coroutine\Coroutine\Fiber; +use Workerman\Worker; +use Workerman\Coroutine\Coroutine\Swoole as SwooleCoroutine; +use Workerman\Coroutine\Coroutine\Swow as SwowCoroutine; +use Workerman\Events\Swoole as SwooleEvent; +use Workerman\Events\Swow as SwowEvent; + +/** + * Class Coroutine + */ +class Coroutine implements CoroutineInterface +{ + /** + * @var class-string + */ + protected static string $driverClass; + + /** + * @var CoroutineInterface + */ + public CoroutineInterface $driver; + + /** + * Coroutine constructor. + * + * @param callable $callable + */ + public function __construct(callable $callable) + { + $this->driver = new static::$driverClass($callable); + } + + /** + * @inheritDoc + */ + public static function create(callable $callable, ...$args): CoroutineInterface + { + return static::$driverClass::create($callable, ...$args); + } + + /** + * @inheritDoc + */ + public function start(mixed ...$args): mixed + { + return $this->driver->start(...$args); + } + + /** + * @inheritDoc + */ + public function resume(mixed ...$args): mixed + { + return $this->driver->resume(...$args); + } + + /** + * @inheritDoc + */ + public function id(): int + { + return $this->driver->id(); + } + + /** + * @inheritDoc + */ + public static function defer(callable $callable): void + { + static::$driverClass::defer($callable); + } + + /** + * @inheritDoc + */ + public static function suspend(mixed $value = null): mixed + { + return static::$driverClass::suspend($value); + } + + /** + * @inheritDoc + */ + public static function getCurrent(): CoroutineInterface + { + return static::$driverClass::getCurrent(); + } + + /** + * @inheritDoc + */ + public static function isCoroutine(): bool + { + return static::$driverClass::isCoroutine(); + } + + /** + * @return void + */ + public static function init(): void + { + static::$driverClass = match (Worker::$eventLoopClass ?? null) { + SwooleEvent::class => SwooleCoroutine::class, + SwowEvent::class => SwowCoroutine::class, + default => Fiber::class, + }; + } + +} +Coroutine::init(); diff --git a/vendor/workerman/coroutine/src/Coroutine/CoroutineInterface.php b/vendor/workerman/coroutine/src/Coroutine/CoroutineInterface.php new file mode 100644 index 0000000..d443d12 --- /dev/null +++ b/vendor/workerman/coroutine/src/Coroutine/CoroutineInterface.php @@ -0,0 +1,90 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Coroutine; + +use Fiber; +use Swow\Coroutine as SwowCoroutine; + +/** + * Interface CoroutineInterface + */ +interface CoroutineInterface +{ + + /** + * Create a coroutine. + * + * @param callable $callable + * @param ...$data + * @return CoroutineInterface + */ + public static function create(callable $callable, ...$data): CoroutineInterface; + + /** + * Start a coroutine. + * + * @param mixed ...$args + * @return mixed + */ + public function start(mixed ...$args): mixed; + + /** + * Resume a coroutine. + * + * @param mixed ...$args + * @return mixed + */ + public function resume(mixed ...$args): mixed; + + /** + * Get the id of the coroutine. + * + * @return int + */ + public function id(): int; + + /** + * Register a callable to be executed when the current fiber is destroyed + * + * @param callable $callable + * @return void + */ + public static function defer(callable $callable): void; + + /** + * Yield the coroutine. + * + * @param mixed|null $value + * @return mixed + */ + public static function suspend(mixed $value = null): mixed; + + /** + * Get the current coroutine. + * + * @return CoroutineInterface|Fiber|SwowCoroutine|static + */ + public static function getCurrent(): CoroutineInterface|Fiber|SwowCoroutine|static; + + /** + * Check if the current coroutine is in a coroutine. + * + * @return bool + */ + public static function isCoroutine(): bool; + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Coroutine/Fiber.php b/vendor/workerman/coroutine/src/Coroutine/Fiber.php new file mode 100644 index 0000000..60cbe0f --- /dev/null +++ b/vendor/workerman/coroutine/src/Coroutine/Fiber.php @@ -0,0 +1,154 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Coroutine; + +use Fiber as BaseFiber; +use RuntimeException; +use WeakMap; +use Workerman\Coroutine\Utils\DestructionWatcher; + +/** + * Class Fiber + */ +class Fiber implements CoroutineInterface +{ + /** + * @var BaseFiber|null + */ + private ?BaseFiber $fiber; + + /** + * @var WeakMap + */ + private static WeakMap $instances; + + /** + * @var int + */ + private int $id; + + /** + * @param callable|null $callable + */ + public function __construct(?callable $callable = null) + { + static $id = 0; + $this->id = ++$id; + if ($callable) { + $callable = function(...$args) use ($callable) { + try { + $callable(...$args); + } finally { + $this->fiber = null; + } + }; + $this->fiber = new BaseFiber($callable); + self::$instances[$this->fiber] = $this; + } + } + + /** + * @inheritDoc + */ + public static function create(callable $callable, ...$args): CoroutineInterface + { + $fiber = new Fiber($callable); + $fiber->start(...$args); + return $fiber; + } + + /** + * @inheritDoc + */ + public function start(mixed ...$args): mixed + { + return $this->fiber->start(...$args); + } + + /** + * @inheritDoc + */ + public function resume(mixed ...$args): mixed + { + return $this->fiber->resume(...$args); + } + + /** + * @inheritDoc + */ + public static function suspend(mixed $value = null): mixed + { + return BaseFiber::suspend($value); + } + + /** + * @inheritDoc + */ + public function id(): int + { + return $this->id; + } + + /** + * @inheritDoc + */ + public static function defer(callable $callable): void + { + $baseFiber = BaseFiber::getCurrent(); + if ($baseFiber === null) { + throw new RuntimeException('Cannot defer outside of a fiber.'); + } + DestructionWatcher::watch($baseFiber, $callable); + } + + /** + * @inheritDoc + */ + public static function getCurrent(): CoroutineInterface + { + if (!$baseFiber = BaseFiber::getCurrent()) { + throw new RuntimeException('Not in fiber context'); + } + if (!isset(self::$instances[$baseFiber])) { + $fiber = new Fiber(); + $fiber->fiber = $baseFiber; + self::$instances[$baseFiber] = $fiber; + } + return self::$instances[$baseFiber]; + } + + /** + * @inheritDoc + */ + public static function isCoroutine(): bool + { + return BaseFiber::getCurrent() !== null; + } + + /** + * Initialize the fiber. + * + * @return void + */ + public static function init(): void + { + self::$instances = new WeakMap(); + } + +} + +Fiber::init(); diff --git a/vendor/workerman/coroutine/src/Coroutine/Swoole.php b/vendor/workerman/coroutine/src/Coroutine/Swoole.php new file mode 100644 index 0000000..0ba1dec --- /dev/null +++ b/vendor/workerman/coroutine/src/Coroutine/Swoole.php @@ -0,0 +1,148 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Coroutine; + +use RuntimeException; +use Swoole\Coroutine; +use WeakReference; + +class Swoole implements CoroutineInterface +{ + + /** + * @var array + */ + private static array $instances = []; + + /** + * @var int + */ + private int $id = 0; + + /** + * @var callable|null + */ + private $callable; + + /** + * Coroutine constructor. + * + * @param callable|null $callable + */ + public function __construct(?callable $callable = null) + { + $this->callable = $callable; + } + + /** + * @inheritDoc + */ + public static function create(callable $callable, ...$args): CoroutineInterface + { + $id = Coroutine::create($callable, ...$args); + if (isset(self::$instances[$id]) && $coroutine = self::$instances[$id]->get()) { + return $coroutine; + } + $coroutine = new self($callable); + $coroutine->id = $id; + self::$instances[$id] = WeakReference::create($coroutine); + return $coroutine; + } + + /** + * @inheritDoc + */ + public function start(mixed ...$args): CoroutineInterface + { + if ($this->id) { + throw new RuntimeException('Coroutine has already started'); + } + $this->id = Coroutine::create($this->callable, ...$args); + $this->callable = null; + if (isset(self::$instances[$this->id]) && $coroutine = self::$instances[$this->id]->get()) { + return $coroutine; + } + self::$instances[$this->id] = WeakReference::create($this); + return $this; + } + + /** + * @inheritDoc + */ + public function resume(mixed ...$args): mixed + { + return Coroutine::resume($this->id, ...$args); + } + + /** + * @inheritDoc + */ + public function id(): int + { + return $this->id; + } + + /** + * @inheritDoc + */ + public static function defer(callable $callable): void + { + Coroutine::defer($callable); + } + + /** + * @inheritDoc + */ + public static function suspend(mixed $value = null): mixed + { + return Coroutine::suspend($value); + } + + /** + * @inheritDoc + */ + public static function getCurrent(): CoroutineInterface + { + $id = Coroutine::getCid(); + if ($id === -1) { + throw new RuntimeException('Not in coroutine'); + } + if (!isset(self::$instances[$id])) { + $coroutine = new self(); + $coroutine->id = $id; + self::$instances[$id] = WeakReference::create($coroutine); + } + return self::$instances[$id]->get(); + } + + /** + * @inheritDoc + */ + public static function isCoroutine(): bool + { + return Coroutine::getCid() > 0; + } + + /** + * Destructor. + */ + public function __destruct() + { + unset(self::$instances[$this->id]); + } + +} diff --git a/vendor/workerman/coroutine/src/Coroutine/Swow.php b/vendor/workerman/coroutine/src/Coroutine/Swow.php new file mode 100644 index 0000000..44a62ce --- /dev/null +++ b/vendor/workerman/coroutine/src/Coroutine/Swow.php @@ -0,0 +1,91 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine\Coroutine; + +use Swow\Coroutine; + +/** + * Class Swow + */ +class Swow extends Coroutine implements CoroutineInterface +{ + + /** + * @var array + */ + private array $callbacks = []; + + /** + * @inheritDoc + */ + public static function defer(callable $callable): void + { + $coroutine = static::getCurrent(); + $coroutine->callbacks[] = $callable; + } + + /** + * @inheritDoc + */ + public static function create(callable $callable, ...$args): CoroutineInterface + { + return static::run($callable, ...$args); + } + + /** + * @inheritDoc + */ + public function start(mixed ...$args): mixed + { + return $this->resume(...$args); + } + + /** + * @inheritDoc + */ + public function id(): int + { + return $this->getId(); + } + + /** + * @inheritDoc + */ + public static function suspend(mixed $value = null): mixed + { + return Coroutine::yield($value); + } + + /** + * @inheritDoc + */ + public static function isCoroutine(): bool + { + return true; + } + + /** + * Destructor. + */ + public function __destruct() + { + foreach (array_reverse($this->callbacks) as $callable) { + $callable(); + } + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Exception/PoolException.php b/vendor/workerman/coroutine/src/Exception/PoolException.php new file mode 100644 index 0000000..e1c49a7 --- /dev/null +++ b/vendor/workerman/coroutine/src/Exception/PoolException.php @@ -0,0 +1,8 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine; + +use RuntimeException; + +/** + * Class Locker + */ +class Locker +{ + /** + * @var Channel[] + */ + protected static array $channels = []; + + /** + * Lock. + * + * @param string $key + * @return bool + */ + public static function lock(string $key): bool + { + if (!isset(static::$channels[$key])) { + static::$channels[$key] = new Channel(1); + } + return static::$channels[$key]->push(true); + } + + /** + * Unlock. + * + * @param string $key + * @return bool + */ + public static function unlock(string $key): bool + { + if ($channel = static::$channels[$key] ?? null) { + // Must check hasProducers before pop, because pop in swow will wake up the producer, leading to inaccurate judgment. + $hasProducers = $channel->hasProducers(); + $result = $channel->pop(); + if (!$hasProducers) { + $channel->close(); + unset(static::$channels[$key]); + } + return $result; + } + throw new RuntimeException("Unlock failed, because the key $key is not locked"); + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/Parallel.php b/vendor/workerman/coroutine/src/Parallel.php new file mode 100644 index 0000000..f1ebb2b --- /dev/null +++ b/vendor/workerman/coroutine/src/Parallel.php @@ -0,0 +1,109 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine; + +use Throwable; +use Workerman\Coroutine; + +/** + * Class Parallel + */ +class Parallel +{ + /** + * @var Channel|null + */ + protected ?Channel $channel = null; + + /** + * @var array + */ + protected array $callbacks = []; + + /** + * @var array + */ + protected array $results = []; + + /** + * @var array + */ + protected array $exceptions = []; + + /** + * Constructor. + * + * @param int $concurrent + */ + public function __construct(int $concurrent = -1) + { + if ($concurrent > 0) { + $this->channel = new Channel($concurrent); + } + } + + /** + * Add a coroutine. + * + * @param callable $callable + * @param string|null $key + * @return void + */ + public function add(callable $callable, ?string $key = null): void + { + if ($key === null) { + $this->callbacks[] = $callable; + } else { + $this->callbacks[$key] = $callable; + } + } + + /** + * Wait all coroutines complete and return results. + * + * @return array + */ + public function wait(): array + { + $barrier = Barrier::create(); + foreach ($this->callbacks as $key => $callback) { + $this->channel?->push(true); + Coroutine::create(function () use ($callback, $key, $barrier) { + try { + $this->results[$key] = $callback(); + } catch (Throwable $throwable) { + $this->exceptions[$key] = $throwable; + } finally { + $this->channel?->pop(); + } + }); + } + Barrier::wait($barrier); + return $this->results; + } + + /** + * Get failed results. + * + * @return array + */ + public function getExceptions(): array + { + return $this->exceptions; + } + +} diff --git a/vendor/workerman/coroutine/src/Pool.php b/vendor/workerman/coroutine/src/Pool.php new file mode 100644 index 0000000..0171abb --- /dev/null +++ b/vendor/workerman/coroutine/src/Pool.php @@ -0,0 +1,386 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine; + +use Closure; +use Psr\Log\LoggerInterface; +use stdClass; +use Throwable; +use WeakMap; +use Workerman\Coroutine; +use Workerman\Coroutine\Exception\PoolException; +use Workerman\Coroutine\Utils\DestructionWatcher; +use Workerman\Timer; +use Workerman\Worker; + +/** + * Class Pool + */ +class Pool implements PoolInterface +{ + /** + * @var Channel + */ + protected Channel $channel; + + /** + * @var int + */ + protected int $minConnections = 1; + + /** + * @var WeakMap + */ + protected WeakMap $connections; + + /** + * @var ?object + */ + protected ?object $nonCoroutineConnection = null; + + /** + * @var WeakMap + */ + protected WeakMap $lastUsedTimes; + + /** + * @var WeakMap + */ + protected WeakMap $lastHeartbeatTimes; + + /** + * @var Closure|null + */ + protected ?Closure $connectionCreateHandler = null; + + /** + * @var Closure|null + */ + protected ?Closure $connectionDestroyHandler = null; + + /** + * @var Closure|null + */ + protected ?Closure $connectionHeartbeatHandler = null; + + /** + * @var float + */ + protected float $idleTimeout = 60; + + /** + * @var float + */ + protected float $heartbeatInterval = 50; + + /** + * @var float + */ + protected float $waitTimeout = 10; + + /** + * @var LoggerInterface|Closure|null + */ + protected LoggerInterface|Closure|null $logger = null; + + /** + * @var array|string[] + */ + private array $configurableProperties = [ + 'minConnections', + 'idleTimeout', + 'heartbeatInterval', + 'waitTimeout', + ]; + + /** + * Constructor. + * + * @param int $maxConnections + * @param array $config + */ + public function __construct(protected int $maxConnections = 1, protected array $config = []) + { + foreach ($config as $key => $value) { + $camelCaseKey = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $key)))); + if (in_array($camelCaseKey, $this->configurableProperties, true)) { + $this->$camelCaseKey = $value; + } + } + + $this->channel = new Channel($maxConnections); + $this->lastUsedTimes = new WeakMap(); + $this->lastHeartbeatTimes = new WeakMap(); + $this->connections = new WeakMap(); + + if (Worker::isRunning()) { + Timer::repeat(1, function () { + $this->checkConnections(); + }); + } + } + + /** + * Set the connection creator. + * + * @param callable $connectionCreateHandler + * @return $this + */ + public function setConnectionCreator(callable $connectionCreateHandler): self + { + $this->connectionCreateHandler = $connectionCreateHandler; + return $this; + } + + /** + * Set the connection closer. + * + * @param callable $connectionDestroyHandler + * @return $this + */ + public function setConnectionCloser(callable $connectionDestroyHandler): self + { + $this->connectionDestroyHandler = $connectionDestroyHandler; + return $this; + } + + /** + * Set the connection heartbeat checker. + * + * @param callable $connectionHeartbeatHandler + * @return $this + */ + public function setHeartbeatChecker(callable $connectionHeartbeatHandler): self + { + $this->connectionHeartbeatHandler = $connectionHeartbeatHandler; + return $this; + } + + /** + * Get connection. + * + * @return object + * @throws Throwable + */ + public function get(): object + { + if (!Coroutine::isCoroutine()) { + if (!$this->nonCoroutineConnection) { + $this->nonCoroutineConnection = $this->createConnection(); + } + return $this->nonCoroutineConnection; + } + $num = $this->channel->length(); + if ($num === 0 && $this->getConnectionCount() < $this->maxConnections) { + return $this->createConnection(); + } + $connection = $this->channel->pop($this->waitTimeout); + if (!$connection) { + throw new PoolException("Failed to get a connection from the pool within the wait timeout ($this->waitTimeout seconds). The connection pool is exhausted."); + } + $this->lastUsedTimes[$connection] = time(); + return $connection; + } + + /** + * Put connection to pool. + * + * @param object $connection + * @return void + * @throws Throwable + */ + public function put(object $connection): void + { + // This connection does not belong to the connection pool. + // It may have been closed by $this->closeConnection($connection). + if (!isset($this->connections[$connection])) { + throw new PoolException('The connection does not belong to the connection pool.'); + } + if ($connection === $this->nonCoroutineConnection) { + return; + } + try { + $this->channel->push($connection); + } catch (Throwable $throwable) { + $this->closeConnection($connection); + throw $throwable; + } + } + + /** + * Check if the connection is valid. + * + * @param $connection + * @return bool + */ + protected function isValidConnection($connection): bool + { + return is_object($connection); + } + + /** + * Create connection. + * + * @return object + * @throws Throwable + */ + public function createConnection(): object + { + if ($this->getConnectionCount() >= $this->maxConnections) { + throw new PoolException('CreateConnection failed, maximum connection limit reached.'); + } + // Create a placeholder to ensure the correct value of getConnectionCount(). + $placeholder = new stdClass; + $this->connections[$placeholder] = 0; + try { + // Coroutines will switch here, so we need $placeholder to ensure the correct value of getConnectionCount(). + $connection = ($this->connectionCreateHandler)(); + if (!$this->isValidConnection($connection)) { + throw new PoolException('CreateConnection failed, expected a connection object, but got ' . gettype($connection) . '.'); + } + unset($this->connections[$placeholder]); + $this->connections[$connection] = $this->lastUsedTimes[$connection] = $this->lastHeartbeatTimes[$connection] = time(); + } catch (Throwable $throwable) { + unset($this->connections[$placeholder]); + throw $throwable; + } + return $connection; + } + + /** + * Close the connection and remove the connection from the connection pool. + * + * @param object $connection + * @return void + */ + public function closeConnection(object $connection): void + { + if (!isset($this->connections[$connection])) { + return; + } + // Mark this connection as no longer belonging to the connection pool. + unset($this->lastUsedTimes[$connection], $this->lastHeartbeatTimes[$connection], $this->connections[$connection]); + if ($this->nonCoroutineConnection === $connection) { + $this->nonCoroutineConnection = null; + } + if (!$this->connectionDestroyHandler) { + return; + } + try { + ($this->connectionDestroyHandler)($connection); + } catch (Throwable $throwable) { + $this->log($throwable); + } + } + + /** + * Cleanup idle connections. + * + * @return void + */ + protected function checkConnections(): void + { + $num = $this->channel->length(); + $time = time(); + for($i = $num; $i > 0; $i--) { + $connection = $this->channel->pop(0.001); + if (!$connection) { + return; + } + $lastUsedTime = $this->lastUsedTimes[$connection]; + if ($time - $lastUsedTime > $this->idleTimeout && $this->channel->length() >= $this->minConnections) { + $this->closeConnection($connection); + continue; + } + $this->trySendHeartbeat($connection) && $this->channel->push($connection); + } + if ($this->nonCoroutineConnection) { + $this->trySendHeartbeat($this->nonCoroutineConnection); + } + } + + /** + * Try to send heartbeat. + * + * @param $connection + * @return bool + */ + private function trySendHeartbeat($connection): bool + { + $lastHeartbeatTime = $this->lastHeartbeatTimes[$connection] ?? 0; + $time = time(); + if ($this->connectionHeartbeatHandler && $time - $lastHeartbeatTime >= $this->heartbeatInterval) { + try { + ($this->connectionHeartbeatHandler)($connection); + $this->lastHeartbeatTimes[$connection] = $time; + } catch (Throwable $throwable) { + $this->log($throwable); + $this->closeConnection($connection); + return false; + } + } + return true; + } + + /** + * Get the number of connections in the connection pool. + * + * @return int + */ + public function getConnectionCount(): int + { + return count($this->connections); + } + + /** + * Close connections. + * + * @return void + */ + public function closeConnections(): void + { + $num = $this->channel->length(); + for ($i = $num; $i > 0; $i--) { + $connection = $this->channel->pop(0.001); + if (!$connection) { + return; + } + $this->closeConnection($connection); + } + $this->nonCoroutineConnection && $this->closeConnection($this->nonCoroutineConnection); + } + + /** + * Log. + * + * @param $message + * @return void + */ + protected function log($message): void + { + if (!$this->logger) { + echo $message . PHP_EOL; + return; + } + if ($this->logger instanceof Closure) { + ($this->logger)($message); + return; + } + $this->logger->info((string)$message); + } + +} diff --git a/vendor/workerman/coroutine/src/PoolInterface.php b/vendor/workerman/coroutine/src/PoolInterface.php new file mode 100644 index 0000000..90a6279 --- /dev/null +++ b/vendor/workerman/coroutine/src/PoolInterface.php @@ -0,0 +1,69 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Coroutine; + +/** + * Interface PoolInterface + */ +interface PoolInterface +{ + + /** + * Get a connection from the pool. + * + * @return mixed + */ + public function get(): mixed; + + /** + * Put a connection back to the pool. + * + * @param object $connection + * @return void + */ + public function put(object $connection): void; + + /** + * Create a connection. + * + * @return object + */ + public function createConnection(): object; + + /** + * Close the connection and remove the connection from the connection pool. + * + * @param object $connection + * @return void + */ + public function closeConnection(object $connection): void; + + /** + * Get the number of connections in the connection pool. + * + * @return int + */ + public function getConnectionCount(): int; + + /** + * Close connections in the connection pool. + * + * @return void + */ + public function closeConnections(): void; + +} diff --git a/vendor/workerman/coroutine/src/Utils/DestructionWatcher.php b/vendor/workerman/coroutine/src/Utils/DestructionWatcher.php new file mode 100644 index 0000000..c6e6591 --- /dev/null +++ b/vendor/workerman/coroutine/src/Utils/DestructionWatcher.php @@ -0,0 +1,67 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace Workerman\Coroutine\Utils; + +use WeakMap; + +class DestructionWatcher +{ + /** + * @var WeakMap + */ + protected static WeakMap $objects; + + /** + * @var callable[] + */ + protected array $callbacks = []; + + /** + * DestructionWatcher constructor. + * + * @param callable|null $callback + */ + public function __construct(?callable $callback = null) + { + if ($callback) { + $this->callbacks[] = $callback; + } + } + + /** + * DestructionWatcher destructor. + */ + public function __destruct() + { + foreach (array_reverse($this->callbacks) as $callback) { + $callback(); + } + } + + /** + * Watch object destruction. + * + * @param object $object + * @param callable $callback + * @return void + */ + public static function watch(object $object, callable $callback): void + { + static::$objects ??= new WeakMap(); + static::$objects[$object] ??= new static(); + static::$objects[$object]->callbacks[] = $callback; + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/WaitGroup.php b/vendor/workerman/coroutine/src/WaitGroup.php new file mode 100644 index 0000000..9327860 --- /dev/null +++ b/vendor/workerman/coroutine/src/WaitGroup.php @@ -0,0 +1,77 @@ + + */ + protected static string $driverClass; + + /** + * @var WaitGroupInterface + */ + protected WaitGroupInterface $driver; + + /** + * 构造方法 + */ + public function __construct() + { + $this->driver = new (self::driverClass()); + } + + /** + * Get driver class. + * + * @return class-string + */ + protected static function driverClass(): string + { + return static::$driverClass ??= match (Worker::$eventLoopClass ?? null) { + Swoole::class => SwooleWaitGroup::class, + Swow::class => SwowWaitGroup::class, + default => FiberWaitGroup::class, + }; + } + + + /** + * 代理调用WaitGroupInterface方法 + * + * @codeCoverageIgnore 系统魔术方法,忽略覆盖 + * @param string $name + * @param array $arguments + * @return mixed + */ + public function __call(string $name, array $arguments): mixed + { + if (!method_exists($this->driver, $name)) { + throw new BadMethodCallException("Method $name not exists. "); + } + + return $this->driver->$name(...$arguments); + } +} diff --git a/vendor/workerman/coroutine/src/WaitGroup/Fiber.php b/vendor/workerman/coroutine/src/WaitGroup/Fiber.php new file mode 100644 index 0000000..e89463f --- /dev/null +++ b/vendor/workerman/coroutine/src/WaitGroup/Fiber.php @@ -0,0 +1,63 @@ +count = 0; + $this->channel = new Channel(1); + } + + /** @inheritdoc */ + public function add(int $delta = 1): bool + { + $this->count += max($delta, 1); + + return true; + } + + /** @inheritdoc */ + public function done(): bool + { + $this->count--; + if ($this->count <= 0) { + $this->channel->push(true); + } + + return true; + } + + /** @inheritdoc */ + public function count(): int + { + return $this->count; + } + + /** @inheritdoc */ + public function wait(int|float $timeout = -1): bool + { + if ($this->count() > 0) { + return $this->channel->pop($timeout); + } + return true; + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/src/WaitGroup/Swoole.php b/vendor/workerman/coroutine/src/WaitGroup/Swoole.php new file mode 100644 index 0000000..1aed50c --- /dev/null +++ b/vendor/workerman/coroutine/src/WaitGroup/Swoole.php @@ -0,0 +1,63 @@ +waitGroup = new WaitGroup(); + } + + /** @inheritdoc */ + public function add(int $delta = 1): bool + { + $this->waitGroup->add(max($delta, 1)); + + return true; + } + + /** @inheritdoc */ + public function done(): bool + { + if ($this->count() > 0) { + $this->waitGroup->done(); + } + + return true; + } + + /** @inheritdoc */ + public function count(): int + { + return $this->waitGroup->count(); + } + + /** @inheritdoc */ + public function wait(int|float $timeout = -1): bool + { + try { + $this->waitGroup->wait(max($timeout, $timeout > 0 ? 0.001 : -1)); + return true; + } catch (Throwable) { + return false; + } + } + +} diff --git a/vendor/workerman/coroutine/src/WaitGroup/Swow.php b/vendor/workerman/coroutine/src/WaitGroup/Swow.php new file mode 100644 index 0000000..4344148 --- /dev/null +++ b/vendor/workerman/coroutine/src/WaitGroup/Swow.php @@ -0,0 +1,64 @@ +waitGroup = new WaitGroup(); + $this->count = 0; + } + + /** @inheritdoc */ + public function add(int $delta = 1): bool + { + $this->waitGroup->add($delta = max($delta, 1)); + $this->count += $delta; + + return true; + } + + /** @inheritdoc */ + public function done(): bool + { + if ($this->count() > 0) { + $this->count--; + $this->waitGroup->done(); + } + + return true; + } + + /** @inheritdoc */ + public function count(): int + { + return $this->count; + } + + /** @inheritdoc */ + public function wait(int|float $timeout = -1): bool + { + try { + $this->waitGroup->wait($timeout > 0 ? (int) ($timeout * 1000) : $timeout); + return true; + } catch (Throwable) { + return false; + } + } +} diff --git a/vendor/workerman/coroutine/src/WaitGroup/WaitGroupInterface.php b/vendor/workerman/coroutine/src/WaitGroup/WaitGroupInterface.php new file mode 100644 index 0000000..9fbfd7c --- /dev/null +++ b/vendor/workerman/coroutine/src/WaitGroup/WaitGroupInterface.php @@ -0,0 +1,42 @@ +assertNull($barrier, 'Barrier should be null after wait is called.'); + $this->assertEquals([0, 1, 2, 3], $results, 'All coroutines should have been executed.'); + } +} diff --git a/vendor/workerman/coroutine/tests/ChannelTest.php b/vendor/workerman/coroutine/tests/ChannelTest.php new file mode 100644 index 0000000..fefff80 --- /dev/null +++ b/vendor/workerman/coroutine/tests/ChannelTest.php @@ -0,0 +1,313 @@ +assertInstanceOf(Channel::class, $channel); + $this->assertEquals(1, $channel->getCapacity()); + } + + /** + * Test initializing channel with invalid capacities. + */ + #[DataProvider('invalidCapacitiesProvider')] + public function testInitializeWithInvalidCapacity($capacity) + { + $this->expectException(InvalidArgumentException::class); + new Channel($capacity); + } + + /** + * Data provider for invalid capacities. + */ + public static function invalidCapacitiesProvider(): array + { + return [ + [0], + [-1], + [-100] + ]; + } + + /** + * Test pushing and popping data. + */ + public function testPushAndPop() + { + $channel = new Channel(2); + $data1 = 'test data 1'; + $data2 = 'test data 2'; + + // Push data into the channel + $this->assertTrue($channel->push($data1)); + $this->assertTrue($channel->push($data2)); + + // Verify the length of the channel + $this->assertEquals(2, $channel->length()); + + // Pop data from the channel + $this->assertEquals($data1, $channel->pop()); + $this->assertEquals($data2, $channel->pop()); + } + + /** + * Test pushing data when the channel is full. + * @throws ReflectionException + */ + public function testPushWhenFull() + { + // Memory driver does not support push with timeout + if ($this->driverIsMemory()) { + $this->assertTrue(true); + return; + } + $channel = new Channel(1); + $this->assertTrue($channel->push('data1')); + + $timeout = 0.5; + // Attempt to push when the channel is full with a timeout + $startTime = microtime(true); + $this->assertFalse($channel->push('data2', $timeout)); + $elapsedTime = microtime(true) - $startTime; + + // Verify that the push operation timed out + $this->assertTrue(0.1 > abs($elapsedTime - $timeout)); + } + + /** + * Test popping data when the channel is empty. + * @throws ReflectionException + */ + public function testPopWhenEmpty() + { + // Memory driver does not support push with timeout + if ($this->driverIsMemory()) { + $this->assertTrue(true); + return; + } + $channel = new Channel(1); + + // Attempt to pop when the channel is empty with a timeout + $startTime = microtime(true); + $this->assertFalse($channel->pop(0.1)); + $elapsedTime = microtime(true) - $startTime; + + // Verify that the pop operation timed out + $this->assertGreaterThanOrEqual(0.09, $elapsedTime); + } + + /** + * Test closing the channel and its effects. + */ + public function testCloseChannel() + { + $channel = new Channel(1); + $this->assertTrue($channel->push('data')); + + // Close the channel + $channel->close(); + + // Attempt to push after closing + $this->assertFalse($channel->push('new data')); + + // Pop the remaining data + $this->assertEquals('data', $channel->pop()); + + // Attempt to pop after channel is empty and closed + $this->assertFalse($channel->pop()); + } + + /** + * Test that push and pop return false when channel is closed. + */ + public function testPushAndPopReturnFalseWhenClosed() + { + $channel = new Channel(1); + $channel->close(); + + $this->assertFalse($channel->push('data')); + $this->assertFalse($channel->pop()); + } + + /** + * Test the length and capacity methods. + */ + public function testLengthAndCapacity() + { + $channel = new Channel(5); + $this->assertEquals(0, $channel->length()); + $this->assertEquals(5, $channel->getCapacity()); + + $channel->push('data1'); + $channel->push('data2'); + + $this->assertEquals(2, $channel->length()); + } + + /** + * Test pushing and popping with different data types. + */ + #[DataProvider('dataTypesProvider')] + public function testPushAndPopWithDifferentDataTypes($data) + { + $channel = new Channel(1); + $this->assertTrue($channel->push($data)); + $this->assertSame($data, $channel->pop()); + } + + /** + * Data provider for different data types. + */ + public static function dataTypesProvider(): array + { + return [ + ['string'], + [123], + [123.456], + [true], + [false], + [null], + [[]], + [['key' => 'value']], + [new stdClass()], + [fopen('php://memory', 'r')], + ]; + } + + /** + * Test pushing to a closed channel immediately returns false. + */ + public function testPushToClosedChannel() + { + $channel = new Channel(1); + $channel->close(); + $this->assertFalse($channel->push('data', 0)); + } + + /** + * Test popping from a closed and empty channel immediately returns false. + */ + public function testPopFromClosedAndEmptyChannel() + { + $channel = new Channel(1); + $channel->close(); + $this->assertFalse($channel->pop(0)); + } + + /** + * @return bool + * @throws ReflectionException + */ + protected function driverIsMemory(): bool + { + $reflectionClass = new ReflectionClass(Channel::class); + $instance = $reflectionClass->newInstance(); + $property = $reflectionClass->getProperty('driver'); + $driverValue = $property->getValue($instance); + return $driverValue instanceof Memory; + } + + /** + * 测试 hasConsumers 当没有消费者时返回 false + */ + public function testHasConsumersWhenNoConsumers() + { + if (!Coroutine::isCoroutine()) { + $this->assertTrue(true); + return; + } + $channel = new Channel(1); + $this->assertFalse($channel->hasConsumers()); + $channel->close(); + } + + /** + * 测试 hasConsumers 当有消费者等待时返回 true + * @throws ReflectionException + */ + public function testHasConsumersWhenConsumersWaiting() + { + if ($this->driverIsMemory()) { + $this->assertTrue(true); + return; + } + $channel = new Channel(1); + $sync = new Channel(1); + + Coroutine::create(function () use ($channel, $sync) { + $sync->push(true); + $channel->pop(); + }); + + $sync->pop(); + + $this->assertTrue($channel->hasConsumers()); + + Coroutine::create(function () use ($channel) { + $channel->push('data'); + }); + $channel->close(); + } + + /** + * 测试 hasProducers 当没有生产者时返回 false + * @throws ReflectionException + */ + public function testHasProducersWhenNoProducers() + { + if ($this->driverIsMemory()) { + $this->assertTrue(true); + return; + } + $channel = new Channel(1); + $this->assertFalse($channel->hasProducers()); + $channel->close(); + } + + /** + * 测试 hasProducers 当有生产者等待时返回 true + * @throws ReflectionException + */ + public function testHasProducersWhenProducersWaiting() + { + if ($this->driverIsMemory()) { + $this->assertTrue(true); + return; + } + $channel = new Channel(1); + $channel->push('data1'); + + $sync = new Channel(1); + + Coroutine::create(function () use ($channel, $sync) { + $sync->push(true); + $channel->push('data2'); + }); + + $sync->pop(); + + $this->assertTrue($channel->hasProducers()); + + $channel->pop(); + $channel->close(); + } + +} diff --git a/vendor/workerman/coroutine/tests/ContextTest.php b/vendor/workerman/coroutine/tests/ContextTest.php new file mode 100644 index 0000000..788aeb8 --- /dev/null +++ b/vendor/workerman/coroutine/tests/ContextTest.php @@ -0,0 +1,169 @@ +assertEquals('value', Context::get($key)); + }); + } + + public function testContextGet() + { + Context::reset(new ArrayObject(['not_exist' => 'value'])); + $key = 'testContextGet'; + Context::reset(new ArrayObject([$key => 'value'])); + $context = Context::get(); + $this->assertArrayNotHasKey('not_exist', $context); + $this->assertObjectNotHasProperty('not_exist', $context); + $this->assertArrayHasKey($key, $context); + $this->assertObjectHasProperty($key, $context); + $this->assertEquals('value', $context[$key]); + $this->assertEquals('value', $context->$key); + $this->assertInstanceOf('ArrayObject', $context); + unset($context[$key]); + $this->assertNull(Context::get($key)); + $context[$key] = 'value'; + $this->assertEquals('value', Context::get($key)); + unset($context->$key); + $this->assertNull(Context::get($key)); + $context->$key = 'value'; + $this->assertEquals('value', Context::get($key)); + } + + public function testContextIsolationBetweenCoroutines() + { + $values = []; + + Coroutine::create(function () use (&$values) { + Context::set('key', 'value1'); + $values[] = Context::get('key'); + // Ensure the value is not available after coroutine ends + Context::destroy(); + }); + + Coroutine::create(function () use (&$values) { + Context::set('key', 'value2'); + $values[] = Context::get('key'); + // Ensure the value is not available after coroutine ends + Context::destroy(); + }); + + $this->assertEquals(['value1', 'value2'], $values); + } + + public function testContextDestroyedAfterCoroutineEnds() + { + Coroutine::create(function () { + Context::set('key', 'value'); + $this->assertTrue(Context::has('key')); + // Simulate coroutine end and context destruction + Context::destroy(); + }); + + // After coroutine ends, the context should be destroyed + // Need to simulate this by trying to access context outside coroutine + $this->assertNull(Context::get('key')); + $this->assertFalse(Context::has('key')); + } + + public function testContextHasMethod() + { + Coroutine::create(function () { + $this->assertFalse(Context::has('key')); + Context::set('key', 'value'); + $this->assertTrue(Context::has('key')); + }); + } + + public function testContextResetMethod() + { + Coroutine::create(function () { + Context::reset(new ArrayObject(['key3' => 'value1'])); + Context::reset(new ArrayObject(['key1' => 'value1', 'key2' => 'value2'])); + $this->assertEquals('value1', Context::get('key1')); + $this->assertEquals('value2', Context::get('key2')); + // Test that other keys are not set + $this->assertNull(Context::get('key3')); + }); + } + + public function testContextDataNotSharedBetweenCoroutines() + { + $result = []; + + Coroutine::create(function () use (&$result) { + Context::set('counter', 1); + $result[] = Context::get('counter'); + Context::destroy(); + }); + + Coroutine::create(function () use (&$result) { + $this->assertNull(Context::get('counter')); + Context::set('counter', 2); + $result[] = Context::get('counter'); + Context::destroy(); + }); + + $this->assertEquals([1, 2], $result); + } + + public function testContextDefaultValues() + { + Coroutine::create(function () { + $this->assertEquals('default', Context::get('non_existing_key', 'default')); + }); + } + + public function testContextSetOverrideValue() + { + Coroutine::create(function () { + Context::set('key', 'initial'); + $this->assertEquals('initial', Context::get('key')); + Context::set('key', 'overridden'); + $this->assertEquals('overridden', Context::get('key')); + }); + } + + public function testContextMultipleKeys() + { + Coroutine::create(function () { + Context::set('key1', 'value1'); + Context::set('key2', 'value2'); + $this->assertEquals('value1', Context::get('key1')); + $this->assertEquals('value2', Context::get('key2')); + }); + } + + public function testContextPersistenceWithinCoroutine() + { + Coroutine::create(function () { + Context::set('key', 'value'); + + // Simulate asynchronous operation within coroutine + $this->someAsyncOperation(function () { + $this->assertEquals('value', Context::get('key')); + }); + + // Context should persist throughout the coroutine + $this->assertEquals('value', Context::get('key')); + }); + } + + private function someAsyncOperation(callable $callback) + { + // Simulate async operation + $callback(); + } +} diff --git a/vendor/workerman/coroutine/tests/CoroutineTest.php b/vendor/workerman/coroutine/tests/CoroutineTest.php new file mode 100644 index 0000000..c00f2e2 --- /dev/null +++ b/vendor/workerman/coroutine/tests/CoroutineTest.php @@ -0,0 +1,184 @@ +assertInstanceOf(CoroutineInterface::class, $coroutine); + } + + public function testStartExecutesCoroutine() + { + $value = null; + Coroutine::create(function() use (&$value) { + $value = 'started'; + }); + $this->assertEquals('started', $value); + } + + public function testSuspendAndResumeCoroutine() + { + if (Worker::$eventLoopClass === Swoole::class) { + // Swoole does not support suspend and resume + $this->assertTrue(true); + return; + } + $value = []; + $coroutine = Coroutine::create(function() use (&$value) { + $value[] = 'before suspend'; + $resumedValue = Coroutine::suspend(); + $value[] = 'after resume'; + $value[] = $resumedValue; + }); + $this->assertEquals(['before suspend'], $value); + $coroutine->resume('resumed data'); + unset($coroutine); + gc_collect_cycles(); + $this->assertEquals(['before suspend', 'after resume', 'resumed data'], $value); + } + + public function testGetCurrentReturnsCurrentCoroutine() + { + $currentCoroutine = null; + $coroutine = Coroutine::create(function() use (&$currentCoroutine) { + $currentCoroutine = Coroutine::getCurrent(); + }); + $this->assertSame($coroutine, $currentCoroutine); + } + + public function testCoroutineIdIsInteger() + { + $coroutine = Coroutine::create(function() {}); + $id = $coroutine->id(); + $this->assertIsInt($id); + } + + public function testDeferExecutesAfterCoroutineDestruction() + { + $value = []; + $coroutine = Coroutine::create(function() use (&$value) { + Coroutine::defer(function() use (&$value) { + $value[] = 'defer1'; + }); + Coroutine::defer(function() use (&$value) { + $value[] = 'defer2'; + }); + $value[] = 'before suspend'; + Coroutine::suspend(); + $value[] = 'after resume'; + }); + $this->assertEquals(['before suspend'], $value); + $coroutine->resume(); + unset($coroutine); + gc_collect_cycles(); + $this->assertEquals(['before suspend', 'after resume', 'defer2', 'defer1'], $value); + } + + public function testMultipleCoroutines() + { + $sequence = []; + $coroutine1 = Coroutine::create(function() use (&$sequence) { + $sequence[] = 'coroutine1 start'; + Coroutine::suspend(); + $sequence[] = 'coroutine1 resumed'; + }); + $coroutine2 = Coroutine::create(function() use (&$sequence) { + $sequence[] = 'coroutine2 start'; + Coroutine::suspend(); + $sequence[] = 'coroutine2 resumed'; + }); + $this->assertEquals(['coroutine1 start', 'coroutine2 start'], $sequence); + $coroutine1->resume(); + $coroutine2->resume(); + $this->assertEquals( + ['coroutine1 start', 'coroutine2 start', 'coroutine1 resumed', 'coroutine2 resumed'], + $sequence + ); + } + + public function testCoroutineWithArguments() + { + $result = null; + $coroutine = new Coroutine(function($a, $b) use (&$result) { + $result = $a + $b; + }); + $coroutine->start(2, 3); + $this->assertEquals(5, $result); + } + + public function testSuspendReturnsValue() + { + if (Worker::$eventLoopClass === Swoole::class) { + // Swoole does not support suspend and resume + $this->assertTrue(true); + return; + } + $coroutine = new Coroutine(function() { + $valueFromResume = Coroutine::suspend('first suspend'); + Coroutine::suspend($valueFromResume); + }); + $first_suspend = $coroutine->start(); + $this->assertEquals('first suspend', $first_suspend); + $result = $coroutine->resume('value from resume'); + $this->assertEquals('value from resume', $result); + } + + public function testNestedCoroutines() + { + $sequence = []; + $coroutine = Coroutine::create(function() use (&$sequence) { + $sequence[] = 'outer start'; + $inner = Coroutine::create(function() use (&$sequence) { + $sequence[] = 'inner start'; + Coroutine::suspend(); + $sequence[] = 'inner resumed'; + }); + Coroutine::suspend(); + $sequence[] = 'outer resumed'; + $inner->resume(); + $sequence[] = 'outer end'; + }); + $this->assertEquals(['outer start', 'inner start'], $sequence); + $coroutine->resume(); + $this->assertEquals(['outer start', 'inner start', 'outer resumed', 'inner resumed', 'outer end'], $sequence); + } + + /*public function testCoroutineExceptionHandling() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Test exception'); + Coroutine::create(function() { + throw new \Exception('Test exception'); + }); + }*/ + + public function testDeferOrder() + { + $value = []; + $coroutine = Coroutine::create(function() use (&$value) { + Coroutine::defer(function() use (&$value) { + $value[] = 'defer1'; + }); + Coroutine::defer(function() use (&$value) { + $value[] = 'defer2'; + }); + $value[] = 'coroutine body'; + }); + unset($coroutine); + // Force garbage collection + gc_collect_cycles(); + $this->assertEquals(['coroutine body', 'defer2', 'defer1'], $value); + } + +} + diff --git a/vendor/workerman/coroutine/tests/FiberChannelTest.php b/vendor/workerman/coroutine/tests/FiberChannelTest.php new file mode 100644 index 0000000..10baaf6 --- /dev/null +++ b/vendor/workerman/coroutine/tests/FiberChannelTest.php @@ -0,0 +1,346 @@ +push('test data'); + }); + + $fiber->start(); + + $this->assertEquals('test data', $channel->pop()); + } + + /** + * Test that pop will block until data is available or timeout occurs. + */ + public function testPopWithTimeout() + { + $channel = new Channel(); + + $fiber = new BaseFiber(function() use ($channel) { + $result = $channel->pop(0.5); + $this->assertFalse($result); + }); + + $startTime = microtime(true); + + $fiber->start(); + + // Allow time for the fiber to suspend and wait + Timer::sleep(0.2); // 200 ms + + // Ensure that the fiber is still waiting (not timed out yet) + $this->assertTrue($fiber->isSuspended()); + + // Wait until the timeout should have occurred + Timer::sleep(0.4); // 400 ms + + $endTime = microtime(true); + + $this->assertTrue($fiber->isTerminated()); + $this->assertGreaterThanOrEqual(0.5, $endTime - $startTime); + } + + /** + * Test that push will block when capacity is reached and timeout occurs. + */ + public function testPushWithTimeout() + { + $channel = new Channel(1); + + $this->assertTrue($channel->push('data1')); + + $fiber = new BaseFiber(function() use ($channel) { + $result = $channel->push('data2', 0.5); + $this->assertFalse($result); + }); + + $startTime = microtime(true); + + $fiber->start(); + + // Allow time for the fiber to suspend and wait + Timer::sleep(0.2); // 200 ms + + // Ensure that the fiber is still waiting (not timed out yet) + $this->assertTrue($fiber->isSuspended()); + + // Wait until the timeout should have occurred + Timer::sleep(0.4); // 400 ms + + $endTime = microtime(true); + + $this->assertTrue($fiber->isTerminated()); + $this->assertGreaterThanOrEqual(0.5, $endTime - $startTime); + } + + /** + * Test that push returns false immediately if capacity is full and timeout is zero. + */ + public function testPushNonBlockingWhenFull() + { + $channel = new Channel(1); + + $this->assertTrue($channel->push('data1')); + + $result = $channel->push('data2', 0); + $this->assertFalse($result); + } + + /** + * Test that pop returns false immediately if the channel is empty and timeout is zero. + */ + public function testPopNonBlockingWhenEmpty() + { + $channel = new Channel(); + + $result = $channel->pop(0); + $this->assertFalse($result); + } + + /** + * Test closing the channel. + */ + public function testCloseChannel() + { + $channel = new Channel(); + + $channel->close(); + + $this->assertFalse($channel->push('data')); + $this->assertFalse($channel->pop()); + } + + /** + * Test that waiting pushers and poppers are resumed when the channel is closed. + */ + public function testWaitersAreResumedOnClose() + { + $channelPush = new Channel(1); + $channelPop = new Channel(1); + + $pushFiber = new BaseFiber(function() use ($channelPush) { + $channelPush->push('data', 1); + $result = $channelPush->push('data', 1); + $this->assertFalse($result); + }); + + $popFiber = new BaseFiber(function() use ($channelPop) { + $result = $channelPop->pop(1); + $this->assertFalse($result); + }); + + $pushFiber->start(); + $popFiber->start(); + + // Allow time for fibers to suspend + Timer::sleep(0.1); // 100 ms + + // Close the channel to resume fibers + $channelPush->close(); + $channelPop->close(); + + // Allow time for fibers to process after resuming + Timer::sleep(0.1); // 100 ms + + $this->assertTrue($pushFiber->isTerminated()); + $this->assertTrue($popFiber->isTerminated()); + } + + /** + * Test that length and getCapacity methods return correct values. + */ + public function testLengthAndCapacity() + { + $capacity = 2; + $channel = new Channel($capacity); + + $this->assertEquals(0, $channel->length()); + $this->assertEquals($capacity, $channel->getCapacity()); + + $channel->push('data1'); + $this->assertEquals(1, $channel->length()); + + $channel->push('data2'); + $this->assertEquals(2, $channel->length()); + + $channel->pop(); + $this->assertEquals(1, $channel->length()); + + $channel->pop(); + $this->assertEquals(0, $channel->length()); + } + + /** + * Test pushing to a closed channel. + */ + public function testPushToClosedChannel() + { + $channel = new Channel(); + + $channel->close(); + + $result = $channel->push('data'); + $this->assertFalse($result); + } + + /** + * Test popping from a closed channel. + */ + public function testPopFromClosedChannel() + { + $channel = new Channel(); + + $channel->push('data'); + + $channel->close(); + + $this->assertEquals('data', $channel->pop()); + $this->assertFalse($channel->pop()); + } + + /** + * Test multiple push and pop operations with fibers. + */ + public function testMultiplePushPopWithFibers() + { + $channel = new Channel(2); + + $results = []; + + $producerFiber = new BaseFiber(function() use ($channel) { + $channel->push('data1'); + $channel->push('data2'); + $channel->push('data3'); + }); + + $consumerFiber = new BaseFiber(function() use ($channel, &$results) { + $results[] = $channel->pop(); + $results[] = $channel->pop(); + $results[] = $channel->pop(); + }); + + $producerFiber->start(); + $consumerFiber->start(); + + // Allow time for fibers to execute + usleep(500000); // 500 ms + + $this->assertEquals(['data1', 'data2', 'data3'], $results); + } + + /** + * Test that fibers are properly blocked and resumed in push and pop operations. + */ + public function testFiberBlockingAndResuming() + { + $channel = new Channel(1); + + $pushFiber = new BaseFiber(function() use ($channel) { + $channel->push('data1'); + $channel->push('data2'); + $channel->push('data3'); + }); + + $popFiber = new BaseFiber(function() use ($channel) { + $this->assertEquals('data1', $channel->pop()); + $this->assertEquals('data2', $channel->pop()); + $this->assertEquals('data3', $channel->pop()); + }); + + $pushFiber->start(); + $popFiber->start(); + + // Allow time for fibers to execute + Timer::sleep(0.5); // 500 ms + + $this->assertTrue($pushFiber->isTerminated()); + $this->assertTrue($popFiber->isTerminated()); + } + + /** + * Test that pushing data after capacity is reached blocks until space is available. + */ + public function testPushBlocksWhenFull() + { + $channel = new Channel(1); + + $channel->push('data1'); + + $pushFiber = new BaseFiber(function() use ($channel) { + $channel->push('data2'); + }); + + $popFiber = new BaseFiber(function() use ($channel) { + Timer::sleep(0.2); // Wait before popping + $this->assertEquals('data1', $channel->pop()); + }); + + $pushFiber->start(); + $popFiber->start(); + + // Allow time for fibers to execute + Timer::sleep(0.5); // 500 ms + + $this->assertTrue($pushFiber->isTerminated()); + $this->assertTrue($popFiber->isTerminated()); + } + + /** + * Test that popping data from an empty channel blocks until data is available. + */ + public function testPopBlocksWhenEmpty() + { + $channel = new Channel(); + + $popFiber = new BaseFiber(function() use ($channel) { + $this->assertEquals('data1', $channel->pop()); + }); + + $pushFiber = new BaseFiber(function() use ($channel) { + Timer::sleep(0.2); // Wait before pushing + $channel->push('data1'); + }); + + $popFiber->start(); + $pushFiber->start(); + + // Allow time for fibers to execute + Timer::sleep(0.5); // 500 ms + + $this->assertTrue($pushFiber->isTerminated()); + $this->assertTrue($popFiber->isTerminated()); + } + + /** + * Test pushing and popping with zero timeout. + */ + public function testPushPopWithZeroTimeout() + { + $channel = new Channel(1); + + $this->assertTrue($channel->push('data1')); + + $result = $channel->push('data2', 0); + $this->assertFalse($result); + + $result = $channel->pop(0); + $this->assertEquals('data1', $result); + + $result = $channel->pop(0); + $this->assertFalse($result); + } +} diff --git a/vendor/workerman/coroutine/tests/LockerTest.php b/vendor/workerman/coroutine/tests/LockerTest.php new file mode 100644 index 0000000..b1a18b1 --- /dev/null +++ b/vendor/workerman/coroutine/tests/LockerTest.php @@ -0,0 +1,130 @@ +assertChannelExists($key); + Locker::lock($key); + $timeDiff = microtime(true) - $timeStart; + $this->assertGreaterThan($timeDiff2, $timeDiff); + Locker::unlock($key); + }); + usleep(100000); + $timeDiff2 = microtime(true) - $timeStart; + Locker::unlock($key); + } + + public function testLockAndUnlock() + { + $key = 'testLockAndUnlock'; + $this->assertTrue(Locker::lock($key)); + $this->assertTrue(Locker::unlock($key)); + $this->assertChannelRemoved($key); + } + + public function testUnlockWithoutLockThrowsException() + { + $this->expectException(RuntimeException::class); + Locker::unlock('non_existent_key'); + } + + public function testRelockAfterUnlock() + { + $key = 'testRelockAfterUnlock'; + Locker::lock($key); + Locker::unlock($key); + + $this->assertTrue(Locker::lock($key)); + Locker::unlock($key); + $this->assertChannelRemoved($key); + } + + public function testMultipleCoroutinesLocking() + { + $key = 'testMultipleCoroutinesLocking'; + $results = []; + Coroutine::create(function () use ($key, &$results) { + Coroutine::create(function () use ($key, &$results) { + Locker::lock($key); + $results[] = 'A'; + Timer::sleep(0.1); + usleep(100000); + Locker::unlock($key); + }); + + Coroutine::create(function () use ($key, &$results) { + Timer::sleep(0.05); + Locker::lock($key); + $results[] = 'B'; + Locker::unlock($key); + }); + + Coroutine::create(function () use ($key, &$results) { + Timer::sleep(0.05); + Locker::lock($key); + $results[] = 'C'; + Locker::unlock($key); + }); + + }); + + Timer::sleep(0.3); + $this->assertEquals(['A', 'B', 'C'], $results); + $this->assertChannelRemoved($key); + } + + public function testChannelRemainsWhenWaiting() + { + $key = 'testChannelRemainsWhenWaiting'; + Locker::lock($key); + + Coroutine::create(function () use ($key) { + Coroutine::create(function () use ($key) { + Locker::lock($key); + Locker::unlock($key); + }); + + Locker::unlock($key); + + $this->assertChannelRemoved($key); + }); + } + + private function assertChannelExists(string $key): void + { + $channels = $this->getChannels(); + $this->assertArrayHasKey($key, $channels, "Channel for key '$key' should exist"); + } + + private function assertChannelRemoved(string $key): void + { + $channels = $this->getChannels(); + $this->assertArrayNotHasKey($key, $channels, "Channel for key '$key' should be removed"); + } + + private function getChannels(): array + { + $reflector = new ReflectionClass(Locker::class); + $property = $reflector->getProperty('channels'); + return $property->getValue(); + } + +} \ No newline at end of file diff --git a/vendor/workerman/coroutine/tests/ParallelTest.php b/vendor/workerman/coroutine/tests/ParallelTest.php new file mode 100644 index 0000000..a3d8d08 --- /dev/null +++ b/vendor/workerman/coroutine/tests/ParallelTest.php @@ -0,0 +1,302 @@ +add(function () { + // Simulate some work. + Timer::sleep(0.01); + return 1; + }, 'task1'); + + $parallel->add(function () { + // Simulate some work. + Timer::sleep(0.005); + return 2; + }, 'task2'); + + $results = $parallel->wait(); + + $this->assertEquals(['task1' => 1, 'task2' => 2], $results); + } + + /** + * Test that exceptions thrown in callables are caught and can be retrieved. + */ + public function testExceptions() + { + $parallel = new Parallel(); + + $parallel->add(function () { + throw new \Exception('Test exception'); + }, 'task_with_exception'); + + $parallel->add(function () { + return 'normal result'; + }, 'normal_task'); + + $results = $parallel->wait(); + $exceptions = $parallel->getExceptions(); + + // Check that the normal task result is present. + $this->assertEquals(['normal_task' => 'normal result'], $results); + + // Check that the exception is captured for the failing task. + $this->assertArrayHasKey('task_with_exception', $exceptions); + $this->assertInstanceOf(\Exception::class, $exceptions['task_with_exception']); + $this->assertEquals('Test exception', $exceptions['task_with_exception']->getMessage()); + } + + /** + * Test concurrency control by limiting the number of concurrent tasks. + */ + public function testConcurrencyLimit() + { + $concurrentLimit = 2; + $parallel = new Parallel($concurrentLimit); + + $startTimes = []; + $endTimes = []; + + for ($i = 0; $i < 5; $i++) { + $parallel->add(function () use (&$startTimes, &$endTimes, $i) { + $startTimes[$i] = microtime(true); + // Simulate some work. + Timer::sleep(0.1); // 100 milliseconds + $endTimes[$i] = microtime(true); + return $i; + }, "task{$i}"); + } + + $parallel->wait(); + + // Since we limited concurrency to 2, tasks should finish in batches. + // We'll check that at no point more than $concurrentLimit tasks were running simultaneously. + + // Collect start and end times into an array of intervals. + $intervals = []; + for ($i = 0; $i < 5; $i++) { + $intervals[] = ['start' => $startTimes[$i], 'end' => $endTimes[$i]]; + } + + // Check the maximum number of overlapping intervals does not exceed the concurrency limit. + $maxConcurrent = $this->getMaxConcurrentIntervals($intervals); + + $this->assertLessThanOrEqual($concurrentLimit, $maxConcurrent); + } + + /** + * Helper function to determine the maximum number of overlapping intervals. + * + * @param array $intervals + * @return int + */ + private function getMaxConcurrentIntervals(array $intervals) + { + $events = []; + foreach ($intervals as $interval) { + $events[] = ['time' => $interval['start'], 'type' => 'start']; + $events[] = ['time' => $interval['end'], 'type' => 'end']; + } + + // Sort events by time, 'start' before 'end' if times are equal. + usort($events, function ($a, $b) { + if ($a['time'] == $b['time']) { + return $a['type'] === 'start' ? -1 : 1; + } + return $a['time'] < $b['time'] ? -1 : 1; + }); + + $maxConcurrent = 0; + $currentConcurrent = 0; + + foreach ($events as $event) { + if ($event['type'] === 'start') { + $currentConcurrent++; + if ($currentConcurrent > $maxConcurrent) { + $maxConcurrent = $currentConcurrent; + } + } else { + $currentConcurrent--; + } + } + + return $maxConcurrent; + } + + /** + * Test that callables are executed in parallel when no concurrency limit is set. + */ + public function testParallelExecutionWithoutConcurrencyLimit() + { + $parallel = new Parallel(); + + $startTimes = []; + $endTimes = []; + + $parallel->add(function () use (&$startTimes, &$endTimes) { + $startTimes[] = microtime(true); + Timer::sleep(0.1); // 100 milliseconds + $endTimes[] = microtime(true); + return 'task1'; + }, 'task1'); + + $parallel->add(function () use (&$startTimes, &$endTimes) { + $startTimes[] = microtime(true); + Timer::sleep(0.1);// 100 milliseconds + $endTimes[] = microtime(true); + return 'task2'; + }, 'task2'); + + $parallel->wait(); + + // Calculate total elapsed time. + $totalTime = max($endTimes) - min($startTimes); + + // The total time should be approximately the duration of one task, not the sum of both. + $this->assertLessThan(0.2, $totalTime); + } + + /** + * Test adding callables without specifying keys and ensure results are correctly indexed. + */ + public function testAddWithoutKeys() + { + $parallel = new Parallel(); + + $parallel->add(function () { + return 'result1'; + }); + + $parallel->add(function () { + return 'result2'; + }); + + $results = $parallel->wait(); + + // Since no keys were specified, indices should be 0 and 1. + $this->assertEquals(['result1', 'result2'], $results); + } + + /** + * Test that the Parallel class can handle a large number of tasks. + */ + public function testLargeNumberOfTasks() + { + $parallel = new Parallel(); + + $taskCount = 100; + for ($i = 0; $i < $taskCount; $i++) { + $parallel->add(function () use ($i) { + return $i * $i; + }, "task{$i}"); + } + + $results = $parallel->wait(); + + // Verify that all tasks have been completed and results are correct. + for ($i = 0; $i < $taskCount; $i++) { + $this->assertEquals($i * $i, $results["task{$i}"]); + } + } + + /** + * Test that adding a non-callable throws a TypeError. + */ + public function testAddNonCallable() + { + $this->expectException(\TypeError::class); + + $parallel = new Parallel(); + $parallel->add('not a callable'); + } + + /** + * Test that the wait method can be called multiple times safely. + */ + public function testMultipleWaitCalls() + { + $parallel = new Parallel(); + + $parallel->add(function () { + return 'first call'; + }, 'task1'); + + $resultsFirst = $parallel->wait(); + + $this->assertEquals(['task1' => 'first call'], $resultsFirst); + + // Add another task after first wait. + $parallel->add(function () { + return 'second call'; + }, 'task2'); + + $resultsSecond = $parallel->wait(); + + // Since the callbacks array is not cleared after wait, results should include both tasks. + $this->assertEquals(['task1' => 'first call', 'task2' => 'second call'], $resultsSecond); + } + + /** + * Test that the class properly handles empty tasks (no callables added). + */ + public function testNoTasks() + { + $parallel = new Parallel(); + + $results = $parallel->wait(); + + $this->assertEmpty($results); + } + + /** + * Test that the class handles tasks that return null. + */ + public function testTasksReturningNull() + { + $parallel = new Parallel(); + + $parallel->add(function () { + // No return statement, implicitly returns null. + }, 'nullTask'); + + $results = $parallel->wait(); + + $this->assertArrayHasKey('nullTask', $results); + $this->assertNull($results['nullTask']); + } + + /** + * Test defer can be used in tasks. + */ + public function testWithDefer() + { + $parallel = new Parallel(); + $results = []; + $parallel->add(function () use (&$results) { + Coroutine::defer(function () use (&$results) { + $results[] = 'defer1'; + }); + }); + $parallel->wait(); + $this->assertEquals(['defer1'], $results); + } + +} + diff --git a/vendor/workerman/coroutine/tests/PoolTest.php b/vendor/workerman/coroutine/tests/PoolTest.php new file mode 100644 index 0000000..288eb1e --- /dev/null +++ b/vendor/workerman/coroutine/tests/PoolTest.php @@ -0,0 +1,394 @@ + 2, + 'idle_timeout' => 30, + 'heartbeat_interval' => 10, + 'wait_timeout' => 5, + ]; + $pool = new Pool(10, $config); + + $this->assertEquals(10, $this->getPrivateProperty($pool, 'maxConnections')); + $this->assertEquals(2, $this->getPrivateProperty($pool, 'minConnections')); + $this->assertEquals(30, $this->getPrivateProperty($pool, 'idleTimeout')); + $this->assertEquals(10, $this->getPrivateProperty($pool, 'heartbeatInterval')); + $this->assertEquals(5, $this->getPrivateProperty($pool, 'waitTimeout')); + } + + public function testSetConnectionCreator() + { + $pool = new Pool(5); + $connectionCreator = function () { + return new stdClass(); + }; + $pool->setConnectionCreator($connectionCreator); + $this->assertSame($connectionCreator, $this->getPrivateProperty($pool, 'connectionCreateHandler')); + } + + public function testSetConnectionCloser() + { + $pool = new Pool(5); + $connectionCloser = function ($conn) { + // Close connection. + }; + $pool->setConnectionCloser($connectionCloser); + $this->assertSame($connectionCloser, $this->getPrivateProperty($pool, 'connectionDestroyHandler')); + } + + public function testGetConnection() + { + $pool = new Pool(5); + + $connectionMock = $this->createMock(stdClass::class); + + // 设置连接创建器 + $pool->setConnectionCreator(function () use ($connectionMock) { + return $connectionMock; + }); + + $connection = $pool->get(); + + $this->assertSame($connectionMock, $connection); + $this->assertEquals(1, $this->getCurrentConnections($pool)); + + // 检查 WeakMap 是否更新 + $connections = $this->getPrivateProperty($pool, 'connections'); + $lastUsedTimes = $this->getPrivateProperty($pool, 'lastUsedTimes'); + $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); + + $this->assertTrue($connections->offsetExists($connection)); + $this->assertTrue($lastUsedTimes->offsetExists($connection)); + $this->assertTrue($lastHeartbeatTimes->offsetExists($connection)); + } + + public function testPutConnection() + { + $pool = new Pool(5); + + $connectionMock = $this->createMock(stdClass::class); + + $pool->setConnectionCreator(function () use ($connectionMock) { + return $connectionMock; + }); + + $connection = $pool->get(); + + $pool->put($connection); + + if (Coroutine::isCoroutine()) { + $channel = $this->getPrivateProperty($pool, 'channel'); + $this->assertEquals(1, $channel->length()); + } + + $this->assertEquals(1, $pool->getConnectionCount()); + } + + public function testPutConnectionDoesNotBelong() + { + $this->expectException(PoolException::class); + $this->expectExceptionMessage('The connection does not belong to the connection pool.'); + + $pool = new Pool(5); + $connection = new stdClass(); + + $pool->put($connection); + } + + public function testCreateConnection() + { + $pool = new Pool(5); + $connectionMock = $this->createMock(stdClass::class); + + $pool->setConnectionCreator(function () use ($connectionMock) { + return $connectionMock; + }); + + $connection = $pool->createConnection(); + + $this->assertSame($connectionMock, $connection); + + // 确保 currentConnections 增加 + $this->assertEquals(1, $this->getCurrentConnections($pool)); + + // 检查 WeakMap 是否更新 + $connections = $this->getPrivateProperty($pool, 'connections'); + $lastUsedTimes = $this->getPrivateProperty($pool, 'lastUsedTimes'); + $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); + + $this->assertTrue($connections->offsetExists($connection)); + $this->assertTrue($lastUsedTimes->offsetExists($connection)); + $this->assertTrue($lastHeartbeatTimes->offsetExists($connection)); + } + + public function testCreateMaxConnections() + { + if (in_array(Worker::$eventLoopClass, [Select::class, Event::class])) { + $this->assertTrue(true); + return; + } + $maxConnections = 2; + $pool = new Pool($maxConnections); + + $pool->setConnectionCreator(function () { + Timer::sleep(0.01); + return $this->createMock(stdClass::class); + }); + + $connections = []; + for ($i = 0; $i < 3; $i++) { + Coroutine::create(function () use ($pool, &$connections) { + $connections[] = $pool->get(); + }); + } + + Timer::sleep(0.1); + $this->assertEquals($maxConnections, $this->getCurrentConnections($pool)); + + $lastUsedTimes = $this->getPrivateProperty($pool, 'lastUsedTimes'); + $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); + + $this->assertCount($maxConnections, $lastUsedTimes); + $this->assertCount($maxConnections, $lastHeartbeatTimes); + + foreach ($connections as $connection) { + $pool->put($connection); + } + + } + + public function testCreateConnectionThrowsException() + { + $pool = new Pool(5); + + $pool->setConnectionCreator(function () { + throw new Exception('Failed to create connection'); + }); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to create connection'); + + try { + $pool->createConnection(); + } finally { + // 确保 currentConnections 减少 + $this->assertEquals(0, $this->getCurrentConnections($pool)); + } + } + + public function testCloseConnection() + { + $pool = new Pool(5); + + $connection = $this->createMock(ConnectionMock::class); + + // 模拟连接属于连接池 + $connections = $this->getPrivateProperty($pool, 'connections'); + $connections[$connection] = time(); + + $connection->expects($this->once())->method('close'); + $pool->setConnectionCloser(function ($conn) { + $conn->close(); + }); + + $pool->closeConnection($connection); + + // 确保 currentConnections 减少 + $this->assertEquals(0, $this->getCurrentConnections($pool)); + + // 确保连接从 WeakMap 中移除 + $this->assertFalse($connections->offsetExists($connection)); + } + + public function testCloseConnections() + { + $maxConnections = 5; + + $pool = new Pool($maxConnections); + + $pool->setConnectionCreator(function () { + $connection = $this->createMock(ConnectionMock::class); + $connection->expects($this->once())->method('close'); + return $connection; + }); + + $pool->setConnectionCloser(function ($conn) { + $conn->close(); + }); + + $connections = []; + for ($i = 0; $i < $maxConnections; $i++) { + $connections[] = $pool->get(); + } + + $this->assertEquals(Coroutine::isCoroutine() ? $maxConnections : 1, $this->getCurrentConnections($pool)); + + $pool->closeConnections(); + $this->assertEquals(Coroutine::isCoroutine() ? $maxConnections : 0, $this->getCurrentConnections($pool)); + if (!Coroutine::isCoroutine()) { + return; + } + + foreach ($connections as $connection) { + $pool->put($connection); + } + $this->assertEquals($maxConnections, $this->getCurrentConnections($pool)); + $pool->closeConnections(); + $this->assertEquals(0, $this->getCurrentConnections($pool)); + + $connections = []; + for ($i = 0; $i < $maxConnections; $i++) { + $connections[] = $pool->get(); + } + $this->assertEquals($maxConnections, $this->getCurrentConnections($pool)); + foreach ($connections as $connection) { + $pool->put($connection); + } + $pool->closeConnections(); + unset($connections); + $this->assertEquals(0, $this->getCurrentConnections($pool)); + } + + public function testCloseConnectionWithExceptionInDestroyHandler() + { + $pool = new Pool(5); + + $connection = $this->createMock(stdClass::class); + + // 模拟连接属于连接池 + $connections = $this->getPrivateProperty($pool, 'connections'); + $connections[$connection] = time(); + + $exception = new Exception('Error closing connection'); + + $pool->setConnectionCloser(function ($conn) use ($exception) { + throw $exception; + }); + + // 设置日志记录器 + $loggerMock = $this->createMock(LoggerInterface::class); + $loggerMock->expects($this->once()) + ->method('info') + ->with($this->stringContains('Error closing connection')); + + $this->setPrivateProperty($pool, 'logger', $loggerMock); + + $pool->closeConnection($connection); + + // 确保 currentConnections 减少 + $this->assertEquals(0, $this->getCurrentConnections($pool)); + + // 确保连接从 WeakMap 中移除 + $this->assertFalse($connections->offsetExists($connection)); + } + + public function testHeartbeatChecker() + { + $pool = $this->getMockBuilder(Pool::class) + ->setConstructorArgs([5]) + ->onlyMethods(['closeConnection']) + ->getMock(); + + $connection = $this->createMock(stdClass::class); + + // 设置连接心跳检测器 + $pool->setHeartbeatChecker(function ($conn) { + // 模拟心跳检测 + }); + + // 模拟连接在通道中 + $channel = $this->getPrivateProperty($pool, 'channel'); + $channel->push($connection); + + // 设置连接的上次使用时间和心跳时间 + $connections = $this->getPrivateProperty($pool, 'connections'); + $connections[$connection] = time(); + + $lastUsedTimes = $this->getPrivateProperty($pool, 'lastUsedTimes'); + $lastUsedTimes[$connection] = time(); + + $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); + $lastHeartbeatTimes[$connection] = time() - 100; // 超过心跳间隔 + + // 调用受保护的 checkConnections 方法 + $reflectedMethod = new ReflectionMethod($pool, 'checkConnections'); + $reflectedMethod->invoke($pool); + + // 检查心跳时间是否更新 + $lastHeartbeatTimes = $this->getPrivateProperty($pool, 'lastHeartbeatTimes'); + $this->assertGreaterThan(time() - 2, $lastHeartbeatTimes[$connection]); + } + + public function testConnectionDestroyedWithoutReturn() + { + $pool = new Pool(5); + + // 设置连接创建器 + $pool->setConnectionCreator(function () { + return new stdClass; + }); + + // 获取初始的 currentConnections + $initialConnections = $this->getCurrentConnections($pool); + + // 从连接池获取一个连接 + $connection = $pool->get(); + + // 检查 currentConnections 是否增加 + $this->assertEquals(Coroutine::isCoroutine() ? $initialConnections + 1 : 1, $this->getCurrentConnections($pool)); + + // 不归还连接,并销毁连接对象 + unset($connection); + + // 检查 currentConnections 是否减少 + $this->assertEquals(Coroutine::isCoroutine() ? $initialConnections : 1, $this->getCurrentConnections($pool)); + } + + private function getPrivateProperty($object, string $property) + { + $prop = new ReflectionProperty($object, $property); + return $prop->getValue($object); + } + + private function setPrivateProperty($object, string $property, $value) + { + $prop = new ReflectionProperty($object, $property); + $prop->setValue($object, $value); + } + + private function getCurrentConnections($object): int + { + return $object->getConnectionCount(); + } + +} + +// 定义 ConnectionMock 类用于测试 +class ConnectionMock +{ + public function close() + { + // 模拟关闭连接 + } +} diff --git a/vendor/workerman/coroutine/tests/WaitGroupTest.php b/vendor/workerman/coroutine/tests/WaitGroupTest.php new file mode 100644 index 0000000..17d16fc --- /dev/null +++ b/vendor/workerman/coroutine/tests/WaitGroupTest.php @@ -0,0 +1,56 @@ +assertEquals(0, $waitGroup->count()); + $results = [0]; + $this->assertTrue($waitGroup->add()); + Coroutine::create(function () use ($waitGroup, &$results) { + try { + Timer::sleep(0.1); + $results[] = 1; + } finally { + $this->assertTrue($waitGroup->done()); + } + }); + $this->assertTrue($waitGroup->add()); + Coroutine::create(function () use ($waitGroup, &$results) { + try { + Timer::sleep(0.2); + $results[] = 2; + } finally { + $this->assertTrue($waitGroup->done()); + } + }); + $this->assertTrue($waitGroup->add()); + Coroutine::create(function () use ($waitGroup, &$results) { + try { + Timer::sleep(0.3); + $results[] = 3; + } finally { + $this->assertTrue($waitGroup->done()); + } + }); + $this->assertTrue($waitGroup->wait()); + $this->assertEquals(0, $waitGroup->count(), 'WaitGroup count should be 0 after wait is called.'); + $this->assertEquals([0, 1, 2, 3], $results, 'All coroutines should have been executed.'); + } +} diff --git a/vendor/workerman/coroutine/tests/start.php b/vendor/workerman/coroutine/tests/start.php new file mode 100644 index 0000000..7b8bdc7 --- /dev/null +++ b/vendor/workerman/coroutine/tests/start.php @@ -0,0 +1,109 @@ +run([ + __DIR__ . '/../vendor/bin/phpunit', + ...$phpunitDisplayOptions, + __DIR__ . '/ChannelTest.php', + __DIR__ . '/PoolTest.php', + __DIR__ . '/BarrierTest.php', + __DIR__ . '/ContextTest.php', + __DIR__ . '/WaitGroupTest.php', + ]); + }, Select::class); +} + +if (extension_loaded('event')) { + create_test_worker(function () use ($phpunitDisplayOptions) { + (new PHPUnit\TextUI\Application)->run([ + __DIR__ . '/../vendor/bin/phpunit', + ...$phpunitDisplayOptions, + __DIR__ . '/ChannelTest.php', + __DIR__ . '/PoolTest.php', + __DIR__ . '/BarrierTest.php', + __DIR__ . '/ContextTest.php', + __DIR__ . '/WaitGroupTest.php', + ]); + }, Event::class); +} + +if (class_exists(Revolt\EventLoop::class) && (DIRECTORY_SEPARATOR === '/' || !extension_loaded('swow'))) { + create_test_worker(function () use ($phpunitDisplayOptions) { + (new PHPUnit\TextUI\Application)->run([ + __DIR__ . '/../vendor/bin/phpunit', + ...$phpunitDisplayOptions, + ...glob(__DIR__ . '/*Test.php') + ]); + }, Fiber::class); +} + +if (extension_loaded('Swoole')) { + create_test_worker(function () use ($phpunitDisplayOptions) { + (new PHPUnit\TextUI\Application)->run([ + __DIR__ . '/../vendor/bin/phpunit', + ...$phpunitDisplayOptions, + ...glob(__DIR__ . '/*Test.php') + ]); + }, Swoole::class); +} + +if (extension_loaded('Swow')) { + create_test_worker(function () use ($phpunitDisplayOptions) { + (new PHPUnit\TextUI\Application)->run([ + __DIR__ . '/../vendor/bin/phpunit', + ...$phpunitDisplayOptions, + ...glob(__DIR__ . '/*Test.php') + ]); + }, Swow::class); +} + +function create_test_worker(Closure $callable, $eventLoopClass): void +{ + $worker = new Worker(); + $worker->eventLoop = $eventLoopClass; + $worker->onWorkerStart = function () use ($callable, $eventLoopClass) { + $fp = fopen(__FILE__, 'r+'); + flock($fp, LOCK_EX); + echo PHP_EOL . PHP_EOL. PHP_EOL . '[TEST EVENT-LOOP: ' . basename(str_replace('\\', '/', $eventLoopClass)) . ']' . PHP_EOL; + try { + $callable(); + } catch (Throwable $e) { + echo $e; + } finally { + flock($fp, LOCK_UN); + } + Timer::repeat(1, function () use ($fp) { + if (flock($fp, LOCK_EX | LOCK_NB)) { + if(function_exists('posix_kill')) { + posix_kill(posix_getppid(), SIGINT); + } else { + Worker::stopAll(); + } + } + }); + }; +} + +Worker::runAll(); diff --git a/vendor/workerman/workerman/MIT-LICENSE.txt b/vendor/workerman/workerman/MIT-LICENSE.txt new file mode 100644 index 0000000..6f6ce35 --- /dev/null +++ b/vendor/workerman/workerman/MIT-LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2009-2025 walkor and contributors (see https://github.com/walkor/workerman/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/workerman/workerman/README.md b/vendor/workerman/workerman/README.md new file mode 100644 index 0000000..58cc9a6 --- /dev/null +++ b/vendor/workerman/workerman/README.md @@ -0,0 +1,477 @@ +# Workerman +[![Gitter](https://badges.gitter.im/walkor/Workerman.svg)](https://gitter.im/walkor/Workerman?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=body_badge) +[![Latest Stable Version](https://poser.pugx.org/workerman/workerman/v/stable)](https://packagist.org/packages/workerman/workerman) +[![Total Downloads](https://poser.pugx.org/workerman/workerman/downloads)](https://packagist.org/packages/workerman/workerman) +[![Monthly Downloads](https://poser.pugx.org/workerman/workerman/d/monthly)](https://packagist.org/packages/workerman/workerman) +[![Daily Downloads](https://poser.pugx.org/workerman/workerman/d/daily)](https://packagist.org/packages/workerman/workerman) +[![License](https://poser.pugx.org/workerman/workerman/license)](https://packagist.org/packages/workerman/workerman) + +## What is it +Workerman is an asynchronous event-driven PHP framework with high performance to build fast and scalable network applications. It supports HTTP, WebSocket, custom protocols, coroutines, and connection pools, making it ideal for handling high-concurrency scenarios efficiently. + +## Requires +A POSIX compatible operating system (Linux, OSX, BSD) +POSIX and PCNTL extensions required +Event/Swoole/Swow extension recommended for better performance + +## Installation + +``` +composer require workerman/workerman +``` + +## Documentation + +[https://manual.workerman.net](https://manual.workerman.net) + +## Basic Usage + +### A websocket server +```php +onConnect = function ($connection) { + echo "New connection\n"; +}; + +// Emitted when data received +$ws_worker->onMessage = function ($connection, $data) { + // Send hello $data + $connection->send('Hello ' . $data); +}; + +// Emitted when connection closed +$ws_worker->onClose = function ($connection) { + echo "Connection closed\n"; +}; + +// Run worker +Worker::runAll(); +``` + +### An http server +```php +use Workerman\Worker; + +require_once __DIR__ . '/vendor/autoload.php'; + +// #### http worker #### +$http_worker = new Worker('http://0.0.0.0:2345'); + +// 4 processes +$http_worker->count = 4; + +// Emitted when data received +$http_worker->onMessage = function ($connection, $request) { + //$request->get(); + //$request->post(); + //$request->header(); + //$request->cookie(); + //$request->session(); + //$request->uri(); + //$request->path(); + //$request->method(); + + // Send data to client + $connection->send("Hello World"); +}; + +// Run all workers +Worker::runAll(); +``` + +### A tcp server +```php +use Workerman\Worker; + +require_once __DIR__ . '/vendor/autoload.php'; + +// #### create socket and listen 1234 port #### +$tcp_worker = new Worker('tcp://0.0.0.0:1234'); + +// 4 processes +$tcp_worker->count = 4; + +// Emitted when new connection come +$tcp_worker->onConnect = function ($connection) { + echo "New Connection\n"; +}; + +// Emitted when data received +$tcp_worker->onMessage = function ($connection, $data) { + // Send data to client + $connection->send("Hello $data \n"); +}; + +// Emitted when connection is closed +$tcp_worker->onClose = function ($connection) { + echo "Connection closed\n"; +}; + +Worker::runAll(); +``` + +### Enable SSL +```php + [ + 'local_cert' => '/your/path/of/server.pem', + 'local_pk' => '/your/path/of/server.key', + 'verify_peer' => false, + ] +]; + +// Create a Websocket server with ssl context. +$ws_worker = new Worker('websocket://0.0.0.0:2346', $context); + +// Enable SSL. WebSocket+SSL means that Secure WebSocket (wss://). +// The similar approaches for Https etc. +$ws_worker->transport = 'ssl'; + +$ws_worker->onMessage = function ($connection, $data) { + // Send hello $data + $connection->send('Hello ' . $data); +}; + +Worker::runAll(); +``` + +### AsyncTcpConnection (tcp/ws/text/frame etc...) +```php + +use Workerman\Worker; +use Workerman\Connection\AsyncTcpConnection; + +require_once __DIR__ . '/vendor/autoload.php'; + +$worker = new Worker(); +$worker->onWorkerStart = function () { + // Websocket protocol for client. + $ws_connection = new AsyncTcpConnection('ws://echo.websocket.org:80'); + $ws_connection->onConnect = function ($connection) { + $connection->send('Hello'); + }; + $ws_connection->onMessage = function ($connection, $data) { + echo "Recv: $data\n"; + }; + $ws_connection->onError = function ($connection, $code, $msg) { + echo "Error: $msg\n"; + }; + $ws_connection->onClose = function ($connection) { + echo "Connection closed\n"; + }; + $ws_connection->connect(); +}; + +Worker::runAll(); +``` + +### Coroutine + +Coroutine is used to create coroutines, enabling the execution of asynchronous tasks to improve concurrency performance. + +```php +eventLoop = Swoole::class; // Or Swow::class or Fiber::class + +$worker->onMessage = function (TcpConnection $connection, Request $request) { + Coroutine::create(function () { + echo file_get_contents("http://www.example.com/event/notify"); + }); + $connection->send('ok'); +}; + +Worker::runAll(); +``` + +> Note: Coroutine require Swoole extension or Swow extension or [Fiber revolt/event-loop](https://github.com/revoltphp/event-loop), and the same applies below + +### Barrier +Barrier is used to manage concurrency and synchronization in coroutines. It allows tasks to run concurrently and waits until all tasks are completed, ensuring process synchronization. + +```php +eventLoop = Swoole::class; // Or Swow::class or Fiber::class +$worker->onMessage = function (TcpConnection $connection, Request $request) { + $barrier = Barrier::create(); + for ($i=1; $i<5; $i++) { + Coroutine::create(function () use ($barrier, $i) { + file_get_contents("http://127.0.0.1:8002?task_id=$i"); + }); + } + // Wait all coroutine done + Barrier::wait($barrier); + $connection->send('All Task Done'); +}; + +// Task Server +$task = new Worker('http://0.0.0.0:8002'); +$task->onMessage = function (TcpConnection $connection, Request $request) { + $task_id = $request->get('task_id'); + $message = "Task $task_id Done"; + echo $message . PHP_EOL; + $connection->close($message); +}; + +Worker::runAll(); +``` + +### Parallel +Parallel executes multiple tasks concurrently and collects results. Use add to add tasks and wait to wait for completion and get results. Unlike Barrier, Parallel directly returns the results of each task. + +```php +eventLoop = Swoole::class; // Or Swow::class or Fiber::class +$worker->onMessage = function (TcpConnection $connection, Request $request) { + $parallel = new Parallel(); + for ($i=1; $i<5; $i++) { + $parallel->add(function () use ($i) { + return file_get_contents("http://127.0.0.1:8002?task_id=$i"); + }); + } + $results = $parallel->wait(); + $connection->send(json_encode($results)); // Response: ["Task 1 Done","Task 2 Done","Task 3 Done","Task 4 Done"] +}; + +// Task Server +$task = new Worker('http://0.0.0.0:8002'); +$task->onMessage = function (TcpConnection $connection, Request $request) { + $task_id = $request->get('task_id'); + $message = "Task $task_id Done"; + $connection->close($message); +}; + +Worker::runAll(); +``` + +### Channel + +Channel is a mechanism for communication between coroutines. One coroutine can push data into the channel, while another can pop data from it, enabling synchronization and data sharing between coroutines. + +```php +eventLoop = Swoole::class; // Or Swow::class or Fiber::class +$worker->onMessage = function (TcpConnection $connection, Request $request) { + $channel = new Channel(2); + Coroutine::create(function () use ($channel) { + $channel->push('Task 1 Done'); + }); + Coroutine::create(function () use ($channel) { + $channel->push('Task 2 Done'); + }); + $result = []; + for ($i = 0; $i < 2; $i++) { + $result[] = $channel->pop(); + } + $connection->send(json_encode($result)); // Response: ["Task 1 Done","Task 2 Done"] +}; +Worker::runAll(); +``` + +### Pool + +Pool is used to manage connection or resource pools, improving performance by reusing resources (e.g., database connections). It supports acquiring, returning, creating, and destroying resources. + +```php +setConnectionCreator(function () use ($host, $port) { + $redis = new \Redis(); + $redis->connect($host, $port); + return $redis; + }); + $pool->setConnectionCloser(function ($redis) { + $redis->close(); + }); + $pool->setHeartbeatChecker(function ($redis) { + $redis->ping(); + }); + $this->pool = $pool; + } + public function get(): \Redis + { + return $this->pool->get(); + } + public function put($redis): void + { + $this->pool->put($redis); + } +} + +// Http Server +$worker = new Worker('http://0.0.0.0:8001'); +$worker->eventLoop = Swoole::class; // Or Swow::class or Fiber::class +$worker->onMessage = function (TcpConnection $connection, Request $request) { + static $pool; + if (!$pool) { + $pool = new RedisPool('127.0.0.1', 6379, 10); + } + $redis = $pool->get(); + $redis->set('key', 'hello'); + $value = $redis->get('key'); + $pool->put($redis); + $connection->send($value); +}; + +Worker::runAll(); +``` + + +### Pool for automatic acquisition and release + +```php +get(); + Context::set('pdo', $pdo); + // When the coroutine is destroyed, return the connection to the pool + Coroutine::defer(function () use ($pdo) { + self::$pool->put($pdo); + }); + } + return call_user_func_array([$pdo, $name], $arguments); + } + private static function initializePool(): void + { + self::$pool = new Pool(10); + self::$pool->setConnectionCreator(function () { + return new \PDO('mysql:host=127.0.0.1;dbname=your_database', 'your_username', 'your_password'); + }); + self::$pool->setConnectionCloser(function ($pdo) { + $pdo = null; + }); + self::$pool->setHeartbeatChecker(function ($pdo) { + $pdo->query('SELECT 1'); + }); + } +} + +// Http Server +$worker = new Worker('http://0.0.0.0:8001'); +$worker->eventLoop = Swoole::class; // Or Swow::class or Fiber::class +$worker->onMessage = function (TcpConnection $connection, Request $request) { + $value = Db::query('SELECT NOW() as now')->fetchAll(); + $connection->send(json_encode($value)); +}; + +Worker::runAll(); +``` + +## Available commands +```php start.php start ``` +```php start.php start -d ``` +```php start.php status ``` +```php start.php status -d ``` +```php start.php connections``` +```php start.php stop ``` +```php start.php stop -g ``` +```php start.php restart ``` +```php start.php reload ``` +```php start.php reload -g ``` + +# Benchmarks +https://www.techempower.com/benchmarks/#section=data-r19&hw=ph&test=plaintext&l=zik073-1r + + +### Supported by + +[![JetBrains logo.](https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg)](https://jb.gg/OpenSourceSupport) + + +## Other links with workerman + +[webman](https://github.com/walkor/webman) +[AdapterMan](https://github.com/joanhey/AdapterMan) + +## Donate +PayPal + +## LICENSE + +Workerman is released under the [MIT license](https://github.com/walkor/workerman/blob/master/MIT-LICENSE.txt). diff --git a/vendor/workerman/workerman/SECURITY.md b/vendor/workerman/workerman/SECURITY.md new file mode 100644 index 0000000..c910997 --- /dev/null +++ b/vendor/workerman/workerman/SECURITY.md @@ -0,0 +1,6 @@ +# Security Policy + + +## Reporting a Vulnerability + +Please contact by email walkor@workerman.net diff --git a/vendor/workerman/workerman/composer.json b/vendor/workerman/workerman/composer.json new file mode 100644 index 0000000..d7254d2 --- /dev/null +++ b/vendor/workerman/workerman/composer.json @@ -0,0 +1,65 @@ +{ + "name": "workerman/workerman", + "type": "library", + "keywords": [ + "event-loop", + "asynchronous", + "http", + "framework" + ], + "homepage": "https://www.workerman.net", + "license": "MIT", + "description": "An asynchronous event driven PHP framework for easily building fast, scalable network applications.", + "authors": [ + { + "name": "walkor", + "email": "walkor@workerman.net", + "homepage": "https://www.workerman.net", + "role": "Developer" + } + ], + "support": { + "email": "walkor@workerman.net", + "issues": "https://github.com/walkor/workerman/issues", + "forum": "https://www.workerman.net/questions", + "wiki": "https://www.workerman.net/doc/workerman/", + "source": "https://github.com/walkor/workerman" + }, + "require": { + "php": ">=8.1", + "ext-json": "*", + "workerman/coroutine": "^1.1 || dev-main" + }, + "suggest": { + "ext-event": "For better performance. " + }, + "autoload": { + "psr-4": { + "Workerman\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "conflict": { + "ext-swow": " + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Connection; + +use Exception; +use RuntimeException; +use stdClass; +use Throwable; +use Workerman\Timer; +use Workerman\Worker; +use function class_exists; +use function explode; +use function function_exists; +use function is_resource; +use function method_exists; +use function microtime; +use function parse_url; +use function socket_import_stream; +use function socket_set_option; +use function stream_context_create; +use function stream_set_blocking; +use function stream_set_read_buffer; +use function stream_socket_client; +use function stream_socket_get_name; +use function ucfirst; +use const DIRECTORY_SEPARATOR; +use const PHP_INT_MAX; +use const SO_KEEPALIVE; +use const SOL_SOCKET; +use const SOL_TCP; +use const STREAM_CLIENT_ASYNC_CONNECT; +use const TCP_NODELAY; + +/** + * AsyncTcpConnection. + */ +class AsyncTcpConnection extends TcpConnection +{ + /** + * PHP built-in protocols. + * + * @var array + */ + public const BUILD_IN_TRANSPORTS = [ + 'tcp' => 'tcp', + 'udp' => 'udp', + 'unix' => 'unix', + 'ssl' => 'ssl', + 'sslv2' => 'sslv2', + 'sslv3' => 'sslv3', + 'tls' => 'tls' + ]; + + /** + * Emitted when socket connection is successfully established. + * + * @var ?callable + */ + public $onConnect = null; + + /** + * Emitted when websocket handshake completed (Only work when protocol is ws). + * + * @var ?callable + */ + public $onWebSocketConnect = null; + + /** + * Transport layer protocol. + * + * @var string + */ + public string $transport = 'tcp'; + + /** + * Socks5 proxy. + * + * @var string + */ + public string $proxySocks5 = ''; + + /** + * Http proxy. + * + * @var string + */ + public string $proxyHttp = ''; + + /** + * Http proxy authorization header value. + * + * @var string + */ + public string $proxyAuthorization = ''; + + /** + * Status. + * + * @var int + */ + protected int $status = self::STATUS_INITIAL; + + /** + * Remote host. + * + * @var string + */ + protected string $remoteHost = ''; + + /** + * Remote port. + * + * @var int + */ + protected int $remotePort = 80; + + /** + * Connect start time. + * + * @var float + */ + protected float $connectStartTime = 0; + + /** + * Remote URI. + * + * @var string + */ + protected string $remoteURI = ''; + + /** + * Context option. + * + * @var array + */ + protected array $socketContext = []; + + /** + * Reconnect timer. + * + * @var int + */ + protected int $reconnectTimer = 0; + + /** + * Construct. + * + * @param string $remoteAddress + * @param array $socketContext + */ + public function __construct(string $remoteAddress, array $socketContext = []) + { + $addressInfo = parse_url($remoteAddress); + if (!$addressInfo) { + [$scheme, $this->remoteAddress] = explode(':', $remoteAddress, 2); + if ('unix' === strtolower($scheme)) { + $this->remoteAddress = substr($remoteAddress, strpos($remoteAddress, '/') + 2); + } + if (!$this->remoteAddress) { + throw new RuntimeException('Bad remoteAddress'); + } + } else { + $addressInfo['port'] ??= 0; + $addressInfo['path'] ??= '/'; + if (!isset($addressInfo['query'])) { + $addressInfo['query'] = ''; + } else { + $addressInfo['query'] = '?' . $addressInfo['query']; + } + $this->remoteHost = $addressInfo['host']; + $this->remotePort = $addressInfo['port']; + $this->remoteURI = "{$addressInfo['path']}{$addressInfo['query']}"; + $scheme = $addressInfo['scheme'] ?? 'tcp'; + $this->remoteAddress = 'unix' === strtolower($scheme) + ? substr($remoteAddress, strpos($remoteAddress, '/') + 2) + : $this->remoteHost . ':' . $this->remotePort; + } + + $this->id = $this->realId = self::$idRecorder++; + if (PHP_INT_MAX === self::$idRecorder) { + self::$idRecorder = 0; + } + // Check application layer protocol class. + if (!isset(self::BUILD_IN_TRANSPORTS[$scheme])) { + // Validate scheme contains only safe characters for class name resolution. + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $scheme)) { + throw new RuntimeException("Invalid protocol scheme '$scheme'"); + } + $scheme = ucfirst($scheme); + $this->protocol = '\\Protocols\\' . $scheme; + if (!class_exists($this->protocol)) { + $this->protocol = "\\Workerman\\Protocols\\$scheme"; + if (!class_exists($this->protocol)) { + throw new RuntimeException("class \\Protocols\\$scheme not exist"); + } + } + } else { + $this->transport = self::BUILD_IN_TRANSPORTS[$scheme]; + } + + // For statistics. + ++self::$statistics['connection_count']; + $this->maxSendBufferSize = self::$defaultMaxSendBufferSize; + $this->maxPackageSize = self::$defaultMaxPackageSize; + $this->socketContext = $socketContext; + static::$connections[$this->realId] = $this; + $this->context = new stdClass; + } + + /** + * Reconnect. + * + * @param int $after + * @return void + */ + public function reconnect(int $after = 0): void + { + $this->status = self::STATUS_INITIAL; + static::$connections[$this->realId] = $this; + if ($this->reconnectTimer) { + Timer::del($this->reconnectTimer); + } + if ($after > 0) { + $this->reconnectTimer = Timer::add($after, $this->connect(...), null, false); + return; + } + $this->connect(); + } + + /** + * Do connect. + * + * @return void + */ + public function connect(): void + { + if ($this->status !== self::STATUS_INITIAL && $this->status !== self::STATUS_CLOSING && + $this->status !== self::STATUS_CLOSED) { + return; + } + + $this->eventLoop ??= Worker::getEventLoop(); + + $this->status = self::STATUS_CONNECTING; + $this->connectStartTime = microtime(true); + set_error_handler(fn() => false); + if ($this->transport !== 'unix') { + if (!$this->remotePort) { + $this->remotePort = $this->transport === 'ssl' ? 443 : 80; + $this->remoteAddress = $this->remoteHost . ':' . $this->remotePort; + } + // Open socket connection asynchronously. + if ($this->proxySocks5) { + $this->socketContext['ssl']['peer_name'] = $this->remoteHost; + $context = stream_context_create($this->socketContext); + $this->socket = stream_socket_client("tcp://$this->proxySocks5", $errno, $err_str, 0, STREAM_CLIENT_ASYNC_CONNECT, $context); + } else if ($this->proxyHttp) { + $this->socketContext['ssl']['peer_name'] = $this->remoteHost; + $context = stream_context_create($this->socketContext); + $this->socket = stream_socket_client("tcp://$this->proxyHttp", $errno, $err_str, 0, STREAM_CLIENT_ASYNC_CONNECT, $context); + } else if ($this->socketContext) { + $context = stream_context_create($this->socketContext); + $this->socket = stream_socket_client("tcp://$this->remoteHost:$this->remotePort", + $errno, $err_str, 0, STREAM_CLIENT_ASYNC_CONNECT, $context); + } else { + $this->socket = stream_socket_client("tcp://$this->remoteHost:$this->remotePort", + $errno, $err_str, 0, STREAM_CLIENT_ASYNC_CONNECT); + } + } else { + $this->socket = stream_socket_client("$this->transport://$this->remoteAddress", $errno, $err_str, 0, + STREAM_CLIENT_ASYNC_CONNECT); + } + restore_error_handler(); + // If failed attempt to emit onError callback. + if (!$this->socket || !is_resource($this->socket)) { + $this->emitError(static::CONNECT_FAIL, $err_str); + if ($this->status === self::STATUS_CLOSING) { + $this->destroy(); + } + if ($this->status === self::STATUS_CLOSED) { + $this->onConnect = null; + } + return; + } + + $this->eventLoop ??= Worker::getEventLoop(); + + // Add socket to global event loop waiting connection is successfully established or failed. + $this->eventLoop->onWritable($this->socket, $this->checkConnection(...)); + // For windows. + if (DIRECTORY_SEPARATOR === '\\' && method_exists($this->eventLoop, 'onExcept')) { + $this->eventLoop->onExcept($this->socket, $this->checkConnection(...)); + } + } + + /** + * Try to emit onError callback. + * + * @param int $code + * @param mixed $msg + * @return void + */ + protected function emitError(int $code, mixed $msg): void + { + $this->status = self::STATUS_CLOSING; + if ($this->onError) { + try { + ($this->onError)($this, $code, $msg); + } catch (Throwable $e) { + $this->error($e); + } + } + } + + /** + * CancelReconnect. + */ + public function cancelReconnect(): void + { + if ($this->reconnectTimer) { + Timer::del($this->reconnectTimer); + $this->reconnectTimer = 0; + } + } + + /** + * Get remote address. + * + * @return string + */ + public function getRemoteHost(): string + { + return $this->remoteHost; + } + + /** + * Get remote URI. + * + * @return string + */ + public function getRemoteURI(): string + { + return $this->remoteURI; + } + + /** + * Check connection is successfully established or failed. + * + * @return void + */ + public function checkConnection(): void + { + // Remove EV_EXPECT for windows. + if (DIRECTORY_SEPARATOR === '\\' && method_exists($this->eventLoop, 'offExcept')) { + $this->eventLoop->offExcept($this->socket); + } + // Remove write listener. + $this->eventLoop->offWritable($this->socket); + + if ($this->status !== self::STATUS_CONNECTING) { + return; + } + + // Check socket state. + if ($address = stream_socket_get_name($this->socket, true)) { + // Proxy + if ($this->proxySocks5) { + fwrite($this->socket, chr(5) . chr(1) . chr(0)); + fread($this->socket, 512); + fwrite($this->socket, chr(5) . chr(1) . chr(0) . chr(3) . chr(strlen($this->remoteHost)) . $this->remoteHost . pack("n", $this->remotePort)); + fread($this->socket, 512); + } elseif ($this->proxyHttp) { + $str = "CONNECT $this->remoteHost:$this->remotePort HTTP/1.1\r\n"; + $str .= "Host: $this->remoteHost:$this->remotePort\r\n"; + if ($this->proxyAuthorization !== '') { + $str .= "Proxy-Authorization: $this->proxyAuthorization\r\n"; + } + $str .= "Proxy-Connection: keep-alive\r\n\r\n"; + fwrite($this->socket, $str); + $proxyResponse = fread($this->socket, 512); + if ($proxyResponse && preg_match('/^HTTP\/\d\.\d\s+(\d{3})(?:\s+([^\r\n]+))?/i', $proxyResponse, $match)) { + if ((int)$match[1] !== 200) { + $reason = $match[2] ?? 'Proxy CONNECT failed'; + $this->emitError(static::CONNECT_FAIL, "Proxy CONNECT failed: {$match[1]} $reason"); + if ($this->status === self::STATUS_CLOSING) { + $this->destroy(); + } + if ($this->status === self::STATUS_CLOSED) { + $this->onConnect = null; + } + return; + } + } + } + if (!is_resource($this->socket)) { + $this->emitError(static::CONNECT_FAIL, 'connect ' . $this->remoteAddress . ' fail after ' . round(microtime(true) - $this->connectStartTime, 4) . ' seconds'); + if ($this->status === self::STATUS_CLOSING) { + $this->destroy(); + } + if ($this->status === self::STATUS_CLOSED) { + $this->onConnect = null; + } + return; + } + // Nonblocking. + stream_set_blocking($this->socket, false); + stream_set_read_buffer($this->socket, 0); + // Try to open keepalive for tcp and disable Nagle algorithm. + if (function_exists('socket_import_stream') && $this->transport === 'tcp') { + $socket = socket_import_stream($this->socket); + socket_set_option($socket, SOL_SOCKET, SO_KEEPALIVE, 1); + socket_set_option($socket, SOL_TCP, TCP_NODELAY, 1); + if (defined('TCP_KEEPIDLE') && defined('TCP_KEEPINTVL') && defined('TCP_KEEPCNT')) { + socket_set_option($socket, SOL_TCP, TCP_KEEPIDLE, static::TCP_KEEPALIVE_INTERVAL); + socket_set_option($socket, SOL_TCP, TCP_KEEPINTVL, static::TCP_KEEPALIVE_INTERVAL); + socket_set_option($socket, SOL_TCP, TCP_KEEPCNT, 1); + } + } + // SSL handshake. + if ($this->transport === 'ssl') { + $this->sslHandshakeCompleted = $this->doSslHandshake($this->socket); + if ($this->sslHandshakeCompleted === false) { + return; + } + } else { + // There are some data waiting to send. + if ($this->sendBuffer) { + $this->eventLoop->onWritable($this->socket, $this->baseWrite(...)); + } + } + // Register a listener waiting read event. + $this->eventLoop->onReadable($this->socket, $this->baseRead(...)); + + $this->status = self::STATUS_ESTABLISHED; + $this->remoteAddress = $address; + + // Try to emit onConnect callback. + if ($this->onConnect) { + try { + ($this->onConnect)($this); + } catch (Throwable $e) { + $this->error($e); + } + } + // Try to emit protocol::onConnect + if ($this->protocol && method_exists($this->protocol, 'onConnect')) { + try { + $this->protocol::onConnect($this); + } catch (Throwable $e) { + $this->error($e); + } + } + } else { + // Connection failed. + $this->emitError(static::CONNECT_FAIL, 'connect ' . $this->remoteAddress . ' fail after ' . round(microtime(true) - $this->connectStartTime, 4) . ' seconds'); + if ($this->status === self::STATUS_CLOSING) { + $this->destroy(); + } + if ($this->status === self::STATUS_CLOSED) { + $this->onConnect = null; + } + } + } +} diff --git a/vendor/workerman/workerman/src/Connection/AsyncUdpConnection.php b/vendor/workerman/workerman/src/Connection/AsyncUdpConnection.php new file mode 100644 index 0000000..97b72f8 --- /dev/null +++ b/vendor/workerman/workerman/src/Connection/AsyncUdpConnection.php @@ -0,0 +1,222 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Connection; + +use Exception; +use RuntimeException; +use Throwable; +use Workerman\Protocols\ProtocolInterface; +use Workerman\Worker; +use function class_exists; +use function is_resource; +use function explode; +use function fclose; +use function stream_context_create; +use function stream_set_blocking; +use function stream_socket_client; +use function stream_socket_recvfrom; +use function stream_socket_sendto; +use function strlen; +use function substr; +use function ucfirst; +use const STREAM_CLIENT_CONNECT; + +/** + * AsyncUdpConnection. + */ +class AsyncUdpConnection extends UdpConnection +{ + /** + * Emitted when socket connection is successfully established. + * + * @var ?callable + */ + public $onConnect = null; + + /** + * Emitted when socket connection closed. + * + * @var ?callable + */ + public $onClose = null; + + /** + * Connected or not. + * + * @var bool + */ + protected bool $connected = false; + + /** + * Context option. + * + * @var array + */ + protected array $contextOption = []; + + /** + * Construct. + * + * @param string $remoteAddress + * @throws Throwable + */ + public function __construct($remoteAddress, $contextOption = []) + { + // Get the application layer communication protocol and listening address. + [$scheme, $address] = explode(':', $remoteAddress, 2); + // Check application layer protocol class. + if ($scheme !== 'udp') { + // Validate scheme contains only safe characters for class name resolution. + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $scheme)) { + throw new RuntimeException("Invalid protocol scheme '$scheme'"); + } + $scheme = ucfirst($scheme); + $this->protocol = '\\Protocols\\' . $scheme; + if (!class_exists($this->protocol)) { + $this->protocol = "\\Workerman\\Protocols\\$scheme"; + if (!class_exists($this->protocol)) { + throw new RuntimeException("class \\Protocols\\$scheme not exist"); + } + } + } + + $this->remoteAddress = substr($address, 2); + $this->contextOption = $contextOption; + } + + /** + * For udp package. + * + * @param resource $socket + * @return void + */ + public function baseRead($socket): void + { + $recvBuffer = stream_socket_recvfrom($socket, static::MAX_UDP_PACKAGE_SIZE, 0, $remoteAddress); + if (false === $recvBuffer || empty($remoteAddress)) { + return; + } + + if ($this->onMessage) { + if ($this->protocol) { + $recvBuffer = $this->protocol::decode($recvBuffer, $this); + } + ++ConnectionInterface::$statistics['total_request']; + try { + ($this->onMessage)($this, $recvBuffer); + } catch (Throwable $e) { + $this->error($e); + } + } + } + + /** + * Close connection. + * + * @param mixed $data + * @param bool $raw + * @return void + */ + public function close(mixed $data = null, bool $raw = false): void + { + if ($data !== null) { + $this->send($data, $raw); + } + if ($this->eventLoop) { + $this->eventLoop->offReadable($this->socket); + } + if (is_resource($this->socket)) { + fclose($this->socket); + } + $this->socket = null; // intentionally nullable to mark closed state + $this->connected = false; + // Try to emit onClose callback. + if ($this->onClose) { + try { + ($this->onClose)($this); + } catch (Throwable $e) { + $this->error($e); + } + } + $this->onConnect = $this->onMessage = $this->onClose = $this->eventLoop = $this->errorHandler = null; + } + + /** + * Sends data on the connection. + * + * @param mixed $sendBuffer + * @param bool $raw + * @return bool|null + */ + public function send(mixed $sendBuffer, bool $raw = false): bool|null + { + if (false === $raw && $this->protocol) { + $sendBuffer = $this->protocol::encode($sendBuffer, $this); + if ($sendBuffer === '') { + return null; + } + } + if ($this->connected === false) { + $this->connect(); + } + return strlen($sendBuffer) === stream_socket_sendto($this->socket, $sendBuffer); + } + + /** + * Connect. + * + * @return void + */ + public function connect(): void + { + if ($this->connected === true) { + return; + } + + $this->eventLoop ??= Worker::getEventLoop(); + + if ($this->contextOption) { + $context = stream_context_create($this->contextOption); + $this->socket = stream_socket_client("udp://$this->remoteAddress", $errno, $errmsg, + 30, STREAM_CLIENT_CONNECT, $context); + } else { + $this->socket = stream_socket_client("udp://$this->remoteAddress", $errno, $errmsg); + } + + if (!$this->socket) { + Worker::safeEcho((string)(new Exception($errmsg))); + $this->eventLoop = null; + return; + } + + $this->eventLoop ??= Worker::getEventLoop(); + + stream_set_blocking($this->socket, false); + if ($this->onMessage) { + $this->eventLoop->onReadable($this->socket, $this->baseRead(...)); + } + $this->connected = true; + // Try to emit onConnect callback. + if ($this->onConnect) { + try { + ($this->onConnect)($this); + } catch (Throwable $e) { + $this->error($e); + } + } + } +} diff --git a/vendor/workerman/workerman/src/Connection/ConnectionInterface.php b/vendor/workerman/workerman/src/Connection/ConnectionInterface.php new file mode 100644 index 0000000..a120442 --- /dev/null +++ b/vendor/workerman/workerman/src/Connection/ConnectionInterface.php @@ -0,0 +1,187 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Connection; + +use Throwable; +use Workerman\Events\Event; +use Workerman\Events\EventInterface; +use Workerman\Worker; +use AllowDynamicProperties; + +/** + * ConnectionInterface. + */ +#[AllowDynamicProperties] +abstract class ConnectionInterface +{ + /** + * Connect failed. + * + * @var int + */ + public const CONNECT_FAIL = 1; + + /** + * Send failed. + * + * @var int + */ + public const SEND_FAIL = 2; + + /** + * Statistics for status command. + * + * @var array + */ + public static array $statistics = [ + 'connection_count' => 0, + 'total_request' => 0, + 'throw_exception' => 0, + 'send_fail' => 0, + ]; + + /** + * Application layer protocol. + * The format is like this Workerman\\Protocols\\Http. + * + * @var ?class-string + */ + public ?string $protocol = null; + + /** + * Emitted when data is received. + * + * @var ?callable + */ + public $onMessage = null; + + /** + * Emitted when the other end of the socket sends a FIN packet. + * + * @var ?callable + */ + public $onClose = null; + + /** + * Emitted when an error occurs with connection. + * + * @var ?callable + */ + public $onError = null; + + /** + * @var ?EventInterface + */ + public ?EventInterface $eventLoop = null; + + /** + * @var ?callable + */ + public $errorHandler = null; + + /** + * Sends data on the connection. + * + * @param mixed $sendBuffer + * @param bool $raw + * @return bool|null + */ + abstract public function send(mixed $sendBuffer, bool $raw = false): bool|null; + + /** + * Get remote IP. + * + * @return string + */ + abstract public function getRemoteIp(): string; + + /** + * Get remote port. + * + * @return int + */ + abstract public function getRemotePort(): int; + + /** + * Get remote address. + * + * @return string + */ + abstract public function getRemoteAddress(): string; + + /** + * Get local IP. + * + * @return string + */ + abstract public function getLocalIp(): string; + + /** + * Get local port. + * + * @return int + */ + abstract public function getLocalPort(): int; + + /** + * Get local address. + * + * @return string + */ + abstract public function getLocalAddress(): string; + + /** + * Close connection. + * + * @param mixed $data + * @param bool $raw + * @return void + */ + abstract public function close(mixed $data = null, bool $raw = false): void; + + /** + * Is ipv4. + * + * return bool. + */ + abstract public function isIpV4(): bool; + + /** + * Is ipv6. + * + * return bool. + */ + abstract public function isIpV6(): bool; + + /** + * @param Throwable $exception + * @return void + */ + public function error(Throwable $exception): void + { + if (!$this->errorHandler) { + Worker::stopAll(250, $exception); + return; + } + try { + ($this->errorHandler)($exception); + } catch (Throwable $exception) { + Worker::stopAll(250, $exception); + return; + } + } +} diff --git a/vendor/workerman/workerman/src/Connection/TcpConnection.php b/vendor/workerman/workerman/src/Connection/TcpConnection.php new file mode 100644 index 0000000..38310a5 --- /dev/null +++ b/vendor/workerman/workerman/src/Connection/TcpConnection.php @@ -0,0 +1,1267 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ +declare(strict_types=1); + +namespace Workerman\Connection; + +use JsonSerializable; +use RuntimeException; +use stdClass; +use Throwable; +use Workerman\Events\EventInterface; +use Workerman\Protocols\Http; +use Workerman\Protocols\Http\Request; +use Workerman\Timer; +use Workerman\Worker; + +use function ceil; +use function count; +use function fclose; +use function feof; +use function fread; +use function function_exists; +use function fwrite; +use function is_object; +use function is_resource; +use function key; +use function method_exists; +use function posix_getpid; +use function restore_error_handler; +use function set_error_handler; +use function stream_set_blocking; +use function stream_set_read_buffer; +use function stream_socket_shutdown; +use function stream_socket_enable_crypto; +use function stream_socket_get_name; +use function strlen; +use function strrchr; +use function strrpos; +use function substr; +use function var_export; + +use const PHP_INT_MAX; +use const STREAM_CRYPTO_METHOD_SSLv23_CLIENT; +use const STREAM_CRYPTO_METHOD_SSLv23_SERVER; +use const STREAM_CRYPTO_METHOD_SSLv2_CLIENT; +use const STREAM_CRYPTO_METHOD_SSLv2_SERVER; +use const STREAM_SHUT_WR; + +/** + * TcpConnection. + * @property string $websocketType + * @property string|null $websocketClientProtocol + * @property string|null $websocketOrigin + */ +class TcpConnection extends ConnectionInterface implements JsonSerializable +{ + /** + * Read buffer size. + * + * @var int + */ + public const READ_BUFFER_SIZE = 87380; + + /** + * Status initial. + * + * @var int + */ + public const STATUS_INITIAL = 0; + + /** + * Status connecting. + * + * @var int + */ + public const STATUS_CONNECTING = 1; + + /** + * Status connection established. + * + * @var int + */ + public const STATUS_ESTABLISHED = 2; + + /** + * Status ending (graceful close: write -> FIN -> linger/drain -> close). + * + * @var int + */ + public const STATUS_ENDING = 4; + + /** + * Status closing. + * + * @var int + */ + public const STATUS_CLOSING = 8; + + /** + * Status closed. + * + * @var int + */ + public const STATUS_CLOSED = 16; + + /** + * Maximum string length for cache + * + * @var int + */ + public const MAX_CACHE_STRING_LENGTH = 2048; + + /** + * Maximum cache size. + * + * @var int + */ + public const MAX_CACHE_SIZE = 512; + + /** + * Tcp keepalive interval. + */ + public const TCP_KEEPALIVE_INTERVAL = 55; + + /** + * Emitted when socket connection is successfully established. + * + * @var ?callable + */ + public $onConnect = null; + + /** + * Emitted before websocket handshake (Only called when protocol is ws). + * + * @var ?callable + */ + public $onWebSocketConnect = null; + + /** + * Emitted after websocket handshake (Only called when protocol is ws). + * + * @var ?callable + */ + public $onWebSocketConnected = null; + + /** + * Emitted when websocket connection is closed (Only called when protocol is ws). + * + * @var ?callable + */ + public $onWebSocketClose = null; + + /** + * Emitted when data is received. + * + * @var ?callable + */ + public $onMessage = null; + + /** + * Emitted when the other end of the socket sends a FIN packet. + * + * @var ?callable + */ + public $onClose = null; + + /** + * Emitted when an error occurs with connection. + * + * @var ?callable + */ + public $onError = null; + + /** + * Emitted when the send buffer becomes full. + * + * @var ?callable + */ + public $onBufferFull = null; + + /** + * Emitted when send buffer becomes empty. + * + * @var ?callable + */ + public $onBufferDrain = null; + + /** + * Transport (tcp/udp/unix/ssl). + * + * @var string + */ + public string $transport = 'tcp'; + + /** + * Which worker belong to. + * + * @var ?Worker + */ + public ?Worker $worker = null; + + /** + * Bytes read. + * + * @var int + */ + public int $bytesRead = 0; + + /** + * Bytes written. + * + * @var int + */ + public int $bytesWritten = 0; + + /** + * Connection->id. + * + * @var int + */ + public int $id = 0; + + /** + * A copy of $worker->id which used to clean up the connection in worker->connections + * + * @var int + */ + protected int $realId = 0; + + /** + * Sets the maximum send buffer size for the current connection. + * OnBufferFull callback will be emitted When send buffer is full. + * + * @var int + */ + public int $maxSendBufferSize = 1048576; + + /** + * Context. + * + * @var ?stdClass + */ + public ?stdClass $context = null; + + /** + * Internal use only. Do not access or modify from application code. + * + * @internal Framework internal API + * @deprecated Do not set this property, use $response->header() or $response->widthHeaders() instead + * @var array + */ + public array $headers = []; + + /** + * Is safe. + * + * @var bool + */ + protected bool $isSafe = true; + + /** + * Default send buffer size. + * + * @var int + */ + public static int $defaultMaxSendBufferSize = 1048576; + + /** + * Sets the maximum acceptable packet size for the current connection. + * + * @var int + */ + public int $maxPackageSize = 1048576; + + /** + * Default maximum acceptable packet size. + * + * @var int + */ + public static int $defaultMaxPackageSize = 10485760; + + /** + * Default linger timeout for graceful end (seconds). + * + * @var float + */ + public static float $defaultLingerTimeout = 1.0; + + /** + * Linger timeout for graceful end (seconds). + * + * @var float + */ + public float $lingerTimeout = 1.0; + + /** + * Id recorder. + * + * @var int + */ + protected static int $idRecorder = 1; + + /** + * Socket + * + * @var resource + */ + protected $socket = null; + + /** + * Send buffer. + * + * @var string + */ + protected string $sendBuffer = ''; + + /** + * Receive buffer. + * + * @var string + */ + protected string $recvBuffer = ''; + + /** + * Current package length. + * + * @var int + */ + protected int $currentPackageLength = 0; + + /** + * Connection status. + * + * @var int + */ + protected int $status = self::STATUS_ESTABLISHED; + + /** + * Linger timer id for end(). + * + * @var int + */ + protected int $endLingerTimerId = 0; + + /** + * Whether write side has been shutdown (FIN sent) during end(). + * + * @var bool + */ + protected bool $endWriteShutdown = false; + + /** + * Remote address. + * + * @var string + */ + protected string $remoteAddress = ''; + + /** + * Is paused. + * + * @var bool + */ + protected bool $isPaused = false; + + /** + * SSL handshake completed or not. + * + * @var bool + */ + protected bool|int $sslHandshakeCompleted = false; + + /** + * All connection instances. + * + * @var array + */ + public static array $connections = []; + + /** + * Status to string. + * + * @var array + */ + public const STATUS_TO_STRING = [ + self::STATUS_INITIAL => 'INITIAL', + self::STATUS_CONNECTING => 'CONNECTING', + self::STATUS_ESTABLISHED => 'ESTABLISHED', + self::STATUS_CLOSING => 'CLOSING', + self::STATUS_ENDING => 'ENDING', + self::STATUS_CLOSED => 'CLOSED', + ]; + + /** + * Construct. + * + * @param EventInterface $eventLoop + * @param resource $socket + * @param string $remoteAddress + */ + public function __construct(EventInterface $eventLoop, $socket, string $remoteAddress = '') + { + ++self::$statistics['connection_count']; + $this->id = $this->realId = self::$idRecorder++; + if (self::$idRecorder === PHP_INT_MAX) { + self::$idRecorder = 0; + } + $this->socket = $socket; + stream_set_blocking($this->socket, false); + stream_set_read_buffer($this->socket, 0); + $this->eventLoop = $eventLoop; + $this->eventLoop->onReadable($this->socket, $this->baseRead(...)); + $this->maxSendBufferSize = self::$defaultMaxSendBufferSize; + $this->maxPackageSize = self::$defaultMaxPackageSize; + $this->lingerTimeout = self::$defaultLingerTimeout; + $this->remoteAddress = $remoteAddress; + static::$connections[$this->id] = $this; + $this->context = new stdClass(); + } + + /** + * Get status. + * + * @param bool $rawOutput + * + * @return int|string + */ + public function getStatus(bool $rawOutput = true): int|string + { + if ($rawOutput) { + return $this->status; + } + return self::STATUS_TO_STRING[$this->status]; + } + + /** + * Sends data on the connection. + * + * @param mixed $sendBuffer + * @param bool $raw + * @return bool|null + */ + public function send(mixed $sendBuffer, bool $raw = false): bool|null + { + if ($this->status === self::STATUS_ENDING || $this->status === self::STATUS_CLOSING || $this->status === self::STATUS_CLOSED) { + return false; + } + + // Try to call protocol::encode($sendBuffer) before sending. + if (false === $raw && $this->protocol !== null) { + try { + $sendBuffer = $this->protocol::encode($sendBuffer, $this); + } catch(Throwable $e) { + $this->error($e); + } + if ($sendBuffer === '') { + return null; + } + } + + if ($this->status !== self::STATUS_ESTABLISHED || + ($this->transport === 'ssl' && $this->sslHandshakeCompleted !== true) + ) { + if ($this->sendBuffer && $this->bufferIsFull()) { + ++self::$statistics['send_fail']; + return false; + } + $this->sendBuffer .= $sendBuffer; + $this->checkBufferWillFull(); + return null; + } + + // Attempt to send data directly. + if ($this->sendBuffer === '') { + if ($this->transport === 'ssl') { + $this->eventLoop->onWritable($this->socket, $this->baseWrite(...)); + $this->sendBuffer = $sendBuffer; + $this->checkBufferWillFull(); + return null; + } + $len = 0; + try { + $len = @fwrite($this->socket, $sendBuffer); + } catch (Throwable $e) { + Worker::log($e); + } + // send successful. + if ($len === strlen($sendBuffer)) { + $this->bytesWritten += $len; + return true; + } + // Send only part of the data. + if ($len > 0) { + $this->sendBuffer = substr($sendBuffer, $len); + $this->bytesWritten += $len; + } else { + // Connection closed? + if (!is_resource($this->socket) || feof($this->socket)) { + ++self::$statistics['send_fail']; + if ($this->onError) { + try { + ($this->onError)($this, static::SEND_FAIL, 'client closed'); + } catch (Throwable $e) { + $this->error($e); + } + } + $this->destroy(); + return false; + } + $this->sendBuffer = $sendBuffer; + } + $this->eventLoop->onWritable($this->socket, $this->baseWrite(...)); + // Check if send buffer will be full. + $this->checkBufferWillFull(); + return null; + } + + if ($this->bufferIsFull()) { + ++self::$statistics['send_fail']; + return false; + } + + $this->sendBuffer .= $sendBuffer; + // Check if send buffer is full. + $this->checkBufferWillFull(); + return null; + } + + /** + * Get remote IP. + * + * @return string + */ + public function getRemoteIp(): string + { + $pos = strrpos($this->remoteAddress, ':'); + if ($pos) { + return substr($this->remoteAddress, 0, $pos); + } + return ''; + } + + /** + * Get remote port. + * + * @return int + */ + public function getRemotePort(): int + { + if ($this->remoteAddress) { + return (int)substr(strrchr($this->remoteAddress, ':'), 1); + } + return 0; + } + + /** + * Get remote address. + * + * @return string + */ + public function getRemoteAddress(): string + { + return $this->remoteAddress; + } + + /** + * Get local IP. + * + * @return string + */ + public function getLocalIp(): string + { + $address = $this->getLocalAddress(); + $pos = strrpos($address, ':'); + if (!$pos) { + return ''; + } + return substr($address, 0, $pos); + } + + /** + * Get local port. + * + * @return int + */ + public function getLocalPort(): int + { + $address = $this->getLocalAddress(); + $pos = strrpos($address, ':'); + if (!$pos) { + return 0; + } + return (int)substr(strrchr($address, ':'), 1); + } + + /** + * Get local address. + * + * @return string + */ + public function getLocalAddress(): string + { + if (!is_resource($this->socket)) { + return ''; + } + return (string)@stream_socket_get_name($this->socket, false); + } + + /** + * Get send buffer queue size. + * + * @return integer + */ + public function getSendBufferQueueSize(): int + { + return strlen($this->sendBuffer); + } + + /** + * Get receive buffer queue size. + * + * @return integer + */ + public function getRecvBufferQueueSize(): int + { + return strlen($this->recvBuffer); + } + + /** + * Pauses the reading of data. That is onMessage will not be emitted. Useful to throttle back an upload. + * + * @return void + */ + public function pauseRecv(): void + { + if($this->eventLoop !== null){ + $this->eventLoop->offReadable($this->socket); + } + $this->isPaused = true; + } + + /** + * Resumes reading after a call to pauseRecv. + * + * @return void + */ + public function resumeRecv(): void + { + if ($this->isPaused === true) { + $this->eventLoop->onReadable($this->socket, $this->baseRead(...)); + $this->isPaused = false; + $this->baseRead($this->socket, false); + } + } + + + /** + * Base read handler. + * + * @param resource $socket + * @param bool $checkEof + * @return void + */ + public function baseRead($socket, bool $checkEof = true): void + { + static $requests = []; + // SSL handshake. + if ($this->transport === 'ssl' && $this->sslHandshakeCompleted !== true) { + if ($this->doSslHandshake($socket)) { + $this->sslHandshakeCompleted = true; + if ($this->sendBuffer) { + $this->eventLoop->onWritable($socket, $this->baseWrite(...)); + } + } else { + return; + } + } + + $buffer = ''; + try { + $buffer = @fread($socket, self::READ_BUFFER_SIZE); + } catch (Throwable) { + // do nothing + } + + // Check connection closed. + if ($buffer === '' || $buffer === false) { + if ($checkEof && (!is_resource($socket) || feof($socket) || $buffer === false)) { + $this->destroy(); + return; + } + } else { + $this->bytesRead += strlen($buffer); + if ($this->status === self::STATUS_ENDING) { + return; + } + if ($this->recvBuffer === '') { + if (!isset($buffer[static::MAX_CACHE_STRING_LENGTH]) && isset($requests[$buffer])) { + ++self::$statistics['total_request']; + if ($this->protocol === Http::class) { + $request = $requests[$buffer]; + $request->connection = $this; + try { + ($this->onMessage)($this, $request); + } catch (Throwable $e) { + $this->error($e); + } + $request = clone $request; + $request->destroy(); + $requests[$buffer] = $request; + return; + } + $request = $requests[$buffer]; + try { + ($this->onMessage)($this, $request); + } catch (Throwable $e) { + $this->error($e); + } + return; + } + $this->recvBuffer = $buffer; + } else { + $this->recvBuffer .= $buffer; + } + } + + // If the application layer protocol has been set up. + if ($this->protocol !== null) { + while ($this->recvBuffer !== '' && !$this->isPaused) { + // The current packet length is known. + if ($this->currentPackageLength) { + // Data is not enough for a package. + $recvBufferLength = strlen($this->recvBuffer); + if ($this->currentPackageLength > $recvBufferLength) { + break; + } + } else { + // Get current package length. + try { + $this->currentPackageLength = $this->protocol::input($this->recvBuffer, $this); + } catch (Throwable $e) { + $this->currentPackageLength = -1; + Worker::safeEcho((string)$e); + } + // The packet length is unknown. + if ($this->currentPackageLength === 0) { + break; + } elseif ($this->currentPackageLength > 0 && $this->currentPackageLength <= $this->maxPackageSize) { + // Data is not enough for a package. + // Note: recalculate length here since protocol::input() may call consumeRecvBuffer(). + $recvBufferLength = strlen($this->recvBuffer); + if ($this->currentPackageLength > $recvBufferLength) { + break; + } + } // Wrong package. + else { + Worker::safeEcho((string)(new RuntimeException("Protocol $this->protocol Error package. package_length=" . var_export($this->currentPackageLength, true)))); + $this->destroy(); + return; + } + } + + // The data is enough for a packet. + ++self::$statistics['total_request']; + // The current packet length is equal to the length of the buffer. + if ($recvBufferLength === $this->currentPackageLength) { + $oneRequestBuffer = $this->recvBuffer; + $this->recvBuffer = ''; + } else { + // Get a full package from the buffer. + $oneRequestBuffer = substr($this->recvBuffer, 0, $this->currentPackageLength); + // Remove the current package from receive buffer. + $this->recvBuffer = substr($this->recvBuffer, $this->currentPackageLength); + } + // Reset the current packet length to 0. + $this->currentPackageLength = 0; + try { + if (!isset($oneRequestBuffer[static::MAX_CACHE_STRING_LENGTH]) && isset($requests[$oneRequestBuffer])) { + $request = $requests[$oneRequestBuffer]; + if ($request instanceof Request) { + $request->connection = $this; + ($this->onMessage)($this, $request); + $request = clone $request; + $request->destroy(); + $requests[$oneRequestBuffer] = $request; + } else { + ($this->onMessage)($this, $request); + } + continue; + } + // Decode request buffer before Emitting onMessage callback. + $request = $this->protocol::decode($oneRequestBuffer, $this); + if ((!is_object($request) || $request instanceof Request) && !isset($oneRequestBuffer[static::MAX_CACHE_STRING_LENGTH])) { + ($this->onMessage)($this, $request); + if ($request instanceof Request) { + $request = clone $request; + $request->destroy(); + } + $requests[$oneRequestBuffer] = $request; + if (count($requests) > static::MAX_CACHE_SIZE) { + unset($requests[key($requests)]); + } + continue; + } + ($this->onMessage)($this, $request); + } catch (Throwable $e) { + $this->error($e); + } + } + return; + } + + if ($this->recvBuffer === '' || $this->isPaused) { + return; + } + + // Application protocol is not set. + ++self::$statistics['total_request']; + try { + ($this->onMessage)($this, $this->recvBuffer); + } catch (Throwable $e) { + $this->error($e); + } + // Clean receive buffer. + $this->recvBuffer = ''; + } + + /** + * Base write handler. + * + * @return void + */ + public function baseWrite(): void + { + $len = 0; + try { + if ($this->transport === 'ssl') { + $len = @fwrite($this->socket, $this->sendBuffer, 8192); + } else { + $len = @fwrite($this->socket, $this->sendBuffer); + } + } catch (Throwable) { + } + if ($len === strlen($this->sendBuffer)) { + $this->bytesWritten += $len; + $this->eventLoop->offWritable($this->socket); + $this->sendBuffer = ''; + // Try to emit onBufferDrain callback when send buffer becomes empty. + if ($this->onBufferDrain) { + try { + ($this->onBufferDrain)($this); + } catch (Throwable $e) { + $this->error($e); + } + } + if ($this->status === self::STATUS_ENDING) { + $this->endMaybeShutdownWrite(); + } + if ($this->status === self::STATUS_CLOSING) { + if (!empty($this->context->streamSending)) { + return; + } + $this->destroy(); + } + return; + } + if ($len > 0) { + $this->bytesWritten += $len; + $this->sendBuffer = substr($this->sendBuffer, $len); + } else { + ++self::$statistics['send_fail']; + $this->destroy(); + } + } + + /** + * SSL handshake. + * + * @param resource $socket + * @return bool|int + */ + public function doSslHandshake($socket): bool|int + { + if (!is_resource($socket) || feof($socket)) { + $this->destroy(); + return false; + } + $async = $this instanceof AsyncTcpConnection; + + /** + * We disabled ssl3 because https://blog.qualys.com/ssllabs/2014/10/15/ssl-3-is-dead-killed-by-the-poodle-attack. + * You can enable ssl3 by the codes below. + */ + /*if($async){ + $type = STREAM_CRYPTO_METHOD_SSLv2_CLIENT | STREAM_CRYPTO_METHOD_SSLv23_CLIENT | STREAM_CRYPTO_METHOD_SSLv3_CLIENT; + }else{ + $type = STREAM_CRYPTO_METHOD_SSLv2_SERVER | STREAM_CRYPTO_METHOD_SSLv23_SERVER | STREAM_CRYPTO_METHOD_SSLv3_SERVER; + }*/ + + if ($async) { + $type = STREAM_CRYPTO_METHOD_SSLv2_CLIENT | STREAM_CRYPTO_METHOD_SSLv23_CLIENT; + } else { + $type = STREAM_CRYPTO_METHOD_SSLv2_SERVER | STREAM_CRYPTO_METHOD_SSLv23_SERVER; + } + + // Hidden error. + set_error_handler(static function (int $code, string $msg): bool { + if (!Worker::$daemonize) { + Worker::safeEcho(sprintf("SSL handshake error: %s\n", $msg)); + } + return true; + }); + $ret = stream_socket_enable_crypto($socket, true, $type); + restore_error_handler(); + // Negotiation has failed. + if (false === $ret) { + $this->destroy(); + return false; + } + if (0 === $ret) { + // There isn't enough data and should try again. + return 0; + } + return true; + } + + /** + * This method pulls all the data out of a readable stream, and writes it to the supplied destination. + * + * @param self $dest + * @return void + */ + public function pipe(self $dest): void + { + $this->onMessage = fn ($source, $data) => $dest->send($data); + $this->onClose = fn () => $dest->close(); + $dest->onBufferFull = fn () => $this->pauseRecv(); + $dest->onBufferDrain = fn() => $this->resumeRecv(); + } + + /** + * Remove $length of data from receive buffer. + * + * @param int $length + * @return void + */ + public function consumeRecvBuffer(int $length): void + { + $this->recvBuffer = substr($this->recvBuffer, $length); + } + + /** + * Close connection. + * + * @param mixed $data + * @param bool $raw + * @return void + */ + public function close(mixed $data = null, bool $raw = false): void + { + if ($this->status === self::STATUS_INITIAL || $this->status === self::STATUS_CONNECTING) { + $this->destroy(); + return; + } + + if ($this->status === self::STATUS_CLOSING || $this->status === self::STATUS_CLOSED) { + return; + } + + if ($data !== null) { + $this->send($data, $raw); + } + + $this->status = self::STATUS_CLOSING; + + if ($this->sendBuffer === '') { + $this->destroy(); + } else { + $this->pauseRecv(); + } + } + + /** + * Graceful end connection. + * It tries to: send response -> wait sendBuffer empty -> shutdown write(FIN) -> linger/drain reads -> close(). + * + * @param mixed $data + * @param bool $raw + * @return void + */ + public function end(mixed $data = null, bool $raw = false): void + { + if ($this->status === self::STATUS_INITIAL || $this->status === self::STATUS_CONNECTING) { + $this->destroy(); + return; + } + + if ($this->status === self::STATUS_ENDING || $this->status === self::STATUS_CLOSING || $this->status === self::STATUS_CLOSED) { + return; + } + + if ($data !== null) { + $this->send($data, $raw); + } + + // Enter ending mode: stop protocol parsing and only drain incoming data. + $this->status = self::STATUS_ENDING; + // Disable business callback after end(). + $this->onMessage = static function (self $connection, mixed $data = null): void {}; + $this->recvBuffer = ''; + $this->currentPackageLength = 0; + + // If already flushed to kernel, shutdown write now. Otherwise, baseWrite() will call endMaybeShutdownWrite() + // when sendBuffer becomes empty. + if ($this->sendBuffer === '') { + $this->endMaybeShutdownWrite(); + return; + } + } + + /** + * If in ENDING and sendBuffer is empty, shutdown write side and start linger timer. + * + * @return void + */ + protected function endMaybeShutdownWrite(): void + { + if ($this->status !== self::STATUS_ENDING || $this->endWriteShutdown || $this->sendBuffer !== '') { + return; + } + + if (is_resource($this->socket)) { + try { + @stream_socket_shutdown($this->socket, STREAM_SHUT_WR); + } catch (Throwable) { + // ignore + } + } + $this->endWriteShutdown = true; + + $timeout = $this->lingerTimeout; + if ($timeout <= 0) { + $this->close(); + return; + } + + $this->endLingerTimerId = Timer::delay($timeout, function (): void { + $this->endLingerTimerId = 0; + if ($this->status === self::STATUS_CLOSED) { + return; + } + $this->close(); + }); + } + + /** + * Is ipv4. + * + * return bool. + */ + public function isIpV4(): bool + { + if ($this->transport === 'unix') { + return false; + } + return !str_contains($this->getRemoteIp(), ':'); + } + + /** + * Is ipv6. + * + * return bool. + */ + public function isIpV6(): bool + { + if ($this->transport === 'unix') { + return false; + } + return str_contains($this->getRemoteIp(), ':'); + } + + /** + * Get the real socket. + * + * @return resource + */ + public function getSocket() + { + return $this->socket; + } + + /** + * Check whether send buffer will be full. + * + * @return void + */ + protected function checkBufferWillFull(): void + { + if ($this->onBufferFull && $this->maxSendBufferSize <= strlen($this->sendBuffer)) { + try { + ($this->onBufferFull)($this); + } catch (Throwable $e) { + $this->error($e); + } + } + } + + /** + * Whether send buffer is full. + * + * @return bool + */ + protected function bufferIsFull(): bool + { + // Buffer has been marked as full but still has data to send then the packet is discarded. + if ($this->maxSendBufferSize <= strlen($this->sendBuffer)) { + if ($this->onError) { + try { + ($this->onError)($this, static::SEND_FAIL, 'send buffer full and drop package'); + } catch (Throwable $e) { + $this->error($e); + } + } + return true; + } + return false; + } + + /** + * Whether send buffer is Empty. + * + * @return bool + */ + public function bufferIsEmpty(): bool + { + return empty($this->sendBuffer); + } + + /** + * Destroy connection. + * + * @return void + */ + public function destroy(): void + { + // Avoid repeated calls. + if ($this->status === self::STATUS_CLOSED) { + return; + } + // Remove event listener. + if($this->eventLoop !== null){ + $this->eventLoop->offReadable($this->socket); + $this->eventLoop->offWritable($this->socket); + if (DIRECTORY_SEPARATOR === '\\' && method_exists($this->eventLoop, 'offExcept')) { + $this->eventLoop->offExcept($this->socket); + } + } + + // Close socket. + try { + @fclose($this->socket); + } catch (Throwable) { + } + + $this->status = self::STATUS_CLOSED; + // Try to emit onClose callback. + if ($this->onClose) { + try { + ($this->onClose)($this); + } catch (Throwable $e) { + $this->error($e); + } + } + // Try to emit protocol::onClose + if ($this->protocol && method_exists($this->protocol, 'onClose')) { + try { + $this->protocol::onClose($this); + } catch (Throwable $e) { + $this->error($e); + } + } + $this->sendBuffer = $this->recvBuffer = ''; + $this->currentPackageLength = 0; + $this->isPaused = $this->sslHandshakeCompleted = false; + $this->endWriteShutdown = false; + if ($this->status === self::STATUS_CLOSED) { + // Cleaning up the callback to avoid memory leaks. + $this->onMessage = $this->onClose = $this->onError = $this->onBufferFull = $this->onBufferDrain = $this->eventLoop = $this->errorHandler = null; + // Remove from worker->connections. + if ($this->worker) { + unset($this->worker->connections[$this->realId]); + } + $this->worker = null; + unset(static::$connections[$this->realId]); + } + } + + /** + * Get the json_encode information. + * + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'status' => $this->getStatus(), + 'transport' => $this->transport, + 'getRemoteIp' => $this->getRemoteIp(), + 'remotePort' => $this->getRemotePort(), + 'getRemoteAddress' => $this->getRemoteAddress(), + 'getLocalIp' => $this->getLocalIp(), + 'getLocalPort' => $this->getLocalPort(), + 'getLocalAddress' => $this->getLocalAddress(), + 'isIpV4' => $this->isIpV4(), + 'isIpV6' => $this->isIpV6(), + ]; + } + + /** + * __unserialize. + * + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->isSafe = false; + } + + /** + * Destruct. + * + * @return void + */ + public function __destruct() + { + static $mod; + if (!$this->isSafe) { + return; + } + self::$statistics['connection_count']--; + if (Worker::getGracefulStop()) { + $mod ??= ceil((self::$statistics['connection_count'] + 1) / 3); + + if (0 === self::$statistics['connection_count'] % $mod) { + $pid = function_exists('posix_getpid') ? posix_getpid() : 0; + Worker::log('worker[' . $pid . '] remains ' . self::$statistics['connection_count'] . ' connection(s)'); + } + + if (0 === self::$statistics['connection_count']) { + Worker::stopAll(); + } + } + } +} diff --git a/vendor/workerman/workerman/src/Connection/UdpConnection.php b/vendor/workerman/workerman/src/Connection/UdpConnection.php new file mode 100644 index 0000000..5e85121 --- /dev/null +++ b/vendor/workerman/workerman/src/Connection/UdpConnection.php @@ -0,0 +1,246 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Connection; + +use JsonSerializable; +use Workerman\Protocols\ProtocolInterface; +use function stream_socket_get_name; +use function stream_socket_sendto; +use function strlen; +use function strrchr; +use function strrpos; +use function substr; +use function trim; + +/** + * UdpConnection. + */ +class UdpConnection extends ConnectionInterface implements JsonSerializable +{ + /** + * Max udp package size. + * + * @var int + */ + public const MAX_UDP_PACKAGE_SIZE = 65535; + + /** + * Transport layer protocol. + * + * @var string + */ + public string $transport = 'udp'; + + /** + * Whether the socket is connected (created via stream_socket_client). + * On BSD/macOS, sendto() on a connected UDP socket with a destination address + * returns EISCONN(-1). We must omit the address for connected sockets. + */ + protected bool $connected = false; + + /** + * @param resource|null $socket + */ + public function __construct( + /** @var resource|null */ protected $socket, + protected string $remoteAddress) + { + if (is_resource($socket) && stream_socket_get_name($socket, true) !== false) { + $this->connected = true; + } + } + + /** + * Sends data on the connection. + * + * @param mixed $sendBuffer + * @param bool $raw + * @return bool|null + */ + public function send(mixed $sendBuffer, bool $raw = false): bool|null + { + if (false === $raw && $this->protocol) { + $sendBuffer = $this->protocol::encode($sendBuffer, $this); + if ($sendBuffer === '') { + return null; + } + } + if ($this->connected) { + return strlen($sendBuffer) === stream_socket_sendto($this->socket, $sendBuffer); + } + return strlen($sendBuffer) === stream_socket_sendto($this->socket, $sendBuffer, 0, $this->isIpV6() ? '[' . $this->getRemoteIp() . ']:' . $this->getRemotePort() : $this->remoteAddress); + } + + /** + * Get remote IP. + * + * @return string + */ + public function getRemoteIp(): string + { + $pos = strrpos($this->remoteAddress, ':'); + if ($pos) { + return trim(substr($this->remoteAddress, 0, $pos), '[]'); + } + return ''; + } + + /** + * Get remote port. + * + * @return int + */ + public function getRemotePort(): int + { + if ($this->remoteAddress) { + return (int)substr(strrchr($this->remoteAddress, ':'), 1); + } + return 0; + } + + /** + * Get remote address. + * + * @return string + */ + public function getRemoteAddress(): string + { + return $this->remoteAddress; + } + + /** + * Get local IP. + * + * @return string + */ + public function getLocalIp(): string + { + $address = $this->getLocalAddress(); + $pos = strrpos($address, ':'); + if (!$pos) { + return ''; + } + return substr($address, 0, $pos); + } + + /** + * Get local port. + * + * @return int + */ + public function getLocalPort(): int + { + $address = $this->getLocalAddress(); + $pos = strrpos($address, ':'); + if (!$pos) { + return 0; + } + return (int)substr(strrchr($address, ':'), 1); + } + + /** + * Get local address. + * + * @return string + */ + public function getLocalAddress(): string + { + return is_resource($this->socket) ? (string)@stream_socket_get_name($this->socket, false) : ''; + } + + + /** + * Close connection. + * + * @param mixed $data + * @param bool $raw + * @return void + */ + public function close(mixed $data = null, bool $raw = false): void + { + if ($data !== null) { + $this->send($data, $raw); + } + if ($this->eventLoop) { + $this->eventLoop->offReadable($this->socket); + } + if (is_resource($this->socket)) { + @fclose($this->socket); + } + $this->socket = null; + $this->eventLoop = $this->errorHandler = null; + } + + /** + * Is ipv4. + * + * return bool. + */ + public function isIpV4(): bool + { + if ($this->transport === 'unix') { + return false; + } + return !str_contains($this->getRemoteIp(), ':'); + } + + /** + * Is ipv6. + * + * return bool. + */ + public function isIpV6(): bool + { + if ($this->transport === 'unix') { + return false; + } + return str_contains($this->getRemoteIp(), ':'); + } + + /** + * Get the real socket. + * + * @return resource + */ + /** + * @return resource|null + */ + public function getSocket() + { + return $this->socket; + } + + /** + * Get the json_encode information. + * + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'transport' => $this->transport, + 'getRemoteIp' => $this->getRemoteIp(), + 'remotePort' => $this->getRemotePort(), + 'getRemoteAddress' => $this->getRemoteAddress(), + 'getLocalIp' => $this->getLocalIp(), + 'getLocalPort' => $this->getLocalPort(), + 'getLocalAddress' => $this->getLocalAddress(), + 'isIpV4' => $this->isIpV4(), + 'isIpV6' => $this->isIpV6(), + ]; + } +} diff --git a/vendor/workerman/workerman/src/Events/Ev.php b/vendor/workerman/workerman/src/Events/Ev.php new file mode 100644 index 0000000..67c1652 --- /dev/null +++ b/vendor/workerman/workerman/src/Events/Ev.php @@ -0,0 +1,238 @@ + + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Events; + +/** + * Ev eventloop + */ +final class Ev implements EventInterface +{ + /** + * All listeners for read event. + * + * @var array + */ + private array $readEvents = []; + + /** + * All listeners for write event. + * + * @var array + */ + private array $writeEvents = []; + + /** + * Event listeners of signal. + * + * @var array + */ + private array $eventSignal = []; + + /** + * All timer event listeners. + * + * @var array + */ + private array $eventTimer = []; + + /** + * @var ?callable + */ + private $errorHandler = null; + + /** + * Timer id. + * + * @var int + */ + private static int $timerId = 1; + + /** + * {@inheritdoc} + */ + public function delay(float $delay, callable $func, array $args = []): int + { + $timerId = self::$timerId; + $event = new \EvTimer($delay, 0, function () use ($func, $args, $timerId) { + unset($this->eventTimer[$timerId]); + $this->safeCall($func, $args); + }); + $this->eventTimer[self::$timerId] = $event; + return self::$timerId++; + } + + /** + * {@inheritdoc} + */ + public function offDelay(int $timerId): bool + { + if (isset($this->eventTimer[$timerId])) { + $this->eventTimer[$timerId]->stop(); + unset($this->eventTimer[$timerId]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function offRepeat(int $timerId): bool + { + return $this->offDelay($timerId); + } + + /** + * {@inheritdoc} + */ + public function repeat(float $interval, callable $func, array $args = []): int + { + $event = new \EvTimer($interval, $interval, fn () => $this->safeCall($func, $args)); + $this->eventTimer[self::$timerId] = $event; + return self::$timerId++; + } + + /** + * {@inheritdoc} + */ + public function onReadable($stream, callable $func): void + { + $fdKey = (int)$stream; + $event = new \EvIo($stream, \Ev::READ, fn () => $this->safeCall($func, [$stream])); + $this->readEvents[$fdKey] = $event; + } + + /** + * {@inheritdoc} + */ + public function offReadable($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->readEvents[$fdKey])) { + $this->readEvents[$fdKey]->stop(); + unset($this->readEvents[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onWritable($stream, callable $func): void + { + $fdKey = (int)$stream; + $event = new \EvIo($stream, \Ev::WRITE, fn () => $this->safeCall($func, [$stream])); + $this->writeEvents[$fdKey] = $event; + } + + /** + * {@inheritdoc} + */ + public function offWritable($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->writeEvents[$fdKey])) { + $this->writeEvents[$fdKey]->stop(); + unset($this->writeEvents[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onSignal(int $signal, callable $func): void + { + $event = new \EvSignal($signal, fn () => $this->safeCall($func, [$signal])); + $this->eventSignal[$signal] = $event; + } + + /** + * {@inheritdoc} + */ + public function offSignal(int $signal): bool + { + if (isset($this->eventSignal[$signal])) { + $this->eventSignal[$signal]->stop(); + unset($this->eventSignal[$signal]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function deleteAllTimer(): void + { + foreach ($this->eventTimer as $event) { + $event->stop(); + } + $this->eventTimer = []; + } + + /** + * {@inheritdoc} + */ + public function run(): void + { + \Ev::run(); + } + + /** + * {@inheritdoc} + */ + public function stop(): void + { + \Ev::stop(); + } + + /** + * {@inheritdoc} + */ + public function getTimerCount(): int + { + return count($this->eventTimer); + } + + /** + * {@inheritdoc} + */ + public function setErrorHandler(callable $errorHandler): void + { + $this->errorHandler = $errorHandler; + } + + /** + * @param callable $func + * @param array $args + * @return void + */ + private function safeCall(callable $func, array $args = []): void + { + try { + $func(...$args); + } catch (\Throwable $e) { + if ($this->errorHandler === null) { + echo $e; + } else { + ($this->errorHandler)($e); + } + } + } +} diff --git a/vendor/workerman/workerman/src/Events/Event.php b/vendor/workerman/workerman/src/Events/Event.php new file mode 100644 index 0000000..d7d908e --- /dev/null +++ b/vendor/workerman/workerman/src/Events/Event.php @@ -0,0 +1,294 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Events; + +/** + * libevent eventloop + */ +final class Event implements EventInterface +{ + /** + * Event base. + * + * @var \EventBase + */ + private \EventBase $eventBase; + + /** + * All listeners for read event. + * + * @var array + */ + private array $readEvents = []; + + /** + * All listeners for write event. + * + * @var array + */ + private array $writeEvents = []; + + /** + * Event listeners of signal. + * + * @var array + */ + private array $eventSignal = []; + + /** + * All timer event listeners. + * + * @var array + */ + private array $eventTimer = []; + + /** + * Timer id. + * + * @var int + */ + private int $timerId = 0; + + /** + * Event class name. + * + * @var string + */ + private string $eventClassName = ''; + + /** + * @var ?callable + */ + private $errorHandler = null; + + /** + * Construct. + */ + public function __construct() + { + if (\class_exists('\\\\Event', false)) { + $className = '\\\\Event'; + } else { + $className = '\Event'; + } + $this->eventClassName = $className; + if (\class_exists('\\\\EventBase', false)) { + $className = '\\\\EventBase'; + } else { + $className = '\EventBase'; + } + $this->eventBase = new $className(); + } + + /** + * {@inheritdoc} + */ + public function delay(float $delay, callable $func, array $args = []): int + { + $className = $this->eventClassName; + $timerId = $this->timerId++; + $event = new $className($this->eventBase, -1, $className::TIMEOUT, function () use ($func, $args, $timerId) { + unset($this->eventTimer[$timerId]); + $this->safeCall($func, $args); + }); + if (!$event->addTimer($delay)) { + throw new \RuntimeException("Event::addTimer($delay) failed"); + } + $this->eventTimer[$timerId] = $event; + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function offDelay(int $timerId): bool + { + if (isset($this->eventTimer[$timerId])) { + $this->eventTimer[$timerId]->del(); + unset($this->eventTimer[$timerId]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function offRepeat(int $timerId): bool + { + return $this->offDelay($timerId); + } + + /** + * {@inheritdoc} + */ + public function repeat(float $interval, callable $func, array $args = []): int + { + $className = $this->eventClassName; + $timerId = $this->timerId++; + $event = new $className($this->eventBase, -1, $className::TIMEOUT | $className::PERSIST, function () use ($func, $args) { + $this->safeCall($func, $args); + }); + if (!$event->addTimer($interval)) { + throw new \RuntimeException("Event::addTimer($interval) failed"); + } + $this->eventTimer[$timerId] = $event; + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function onReadable($stream, callable $func): void + { + $className = $this->eventClassName; + $fdKey = (int)$stream; + $event = new $className($this->eventBase, $stream, $className::READ | $className::PERSIST, $func); + if ($event->add()) { + $this->readEvents[$fdKey] = $event; + } + } + + /** + * {@inheritdoc} + */ + public function offReadable($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->readEvents[$fdKey])) { + $this->readEvents[$fdKey]->del(); + unset($this->readEvents[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onWritable($stream, callable $func): void + { + $className = $this->eventClassName; + $fdKey = (int)$stream; + $event = new $className($this->eventBase, $stream, $className::WRITE | $className::PERSIST, $func); + if ($event->add()) { + $this->writeEvents[$fdKey] = $event; + } + } + + /** + * {@inheritdoc} + */ + public function offWritable($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->writeEvents[$fdKey])) { + $this->writeEvents[$fdKey]->del(); + unset($this->writeEvents[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onSignal(int $signal, callable $func): void + { + $className = $this->eventClassName; + $fdKey = $signal; + $event = $className::signal($this->eventBase, $signal, fn () => $this->safeCall($func, [$signal])); + if ($event->add()) { + $this->eventSignal[$fdKey] = $event; + } + } + + /** + * {@inheritdoc} + */ + public function offSignal(int $signal): bool + { + $fdKey = $signal; + if (isset($this->eventSignal[$fdKey])) { + $this->eventSignal[$fdKey]->del(); + unset($this->eventSignal[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function deleteAllTimer(): void + { + foreach ($this->eventTimer as $event) { + $event->del(); + } + $this->eventTimer = []; + } + + /** + * {@inheritdoc} + */ + public function run(): void + { + $this->eventBase->loop(); + } + + /** + * {@inheritdoc} + */ + public function stop(): void + { + $this->eventBase->exit(); + } + + /** + * {@inheritdoc} + */ + public function getTimerCount(): int + { + return \count($this->eventTimer); + } + + /** + * {@inheritdoc} + */ + public function setErrorHandler(callable $errorHandler): void + { + $this->errorHandler = $errorHandler; + } + + /** + * @param callable $func + * @param array $args + * @return void + */ + private function safeCall(callable $func, array $args = []): void + { + try { + $func(...$args); + } catch (\Throwable $e) { + if ($this->errorHandler === null) { + echo $e; + } else { + ($this->errorHandler)($e); + } + } + } +} diff --git a/vendor/workerman/workerman/src/Events/EventInterface.php b/vendor/workerman/workerman/src/Events/EventInterface.php new file mode 100644 index 0000000..4d538c6 --- /dev/null +++ b/vendor/workerman/workerman/src/Events/EventInterface.php @@ -0,0 +1,143 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Events; + +interface EventInterface +{ + /** + * Delay the execution of a callback. + * + * @param float $delay + * @param callable(mixed...): void $func + * @param array $args + * @return int + */ + public function delay(float $delay, callable $func, array $args = []): int; + + /** + * Delete a delay timer. + * + * @param int $timerId + * @return bool + */ + public function offDelay(int $timerId): bool; + + /** + * Repeatedly execute a callback. + * + * @param float $interval + * @param callable(mixed...): void $func + * @param array $args + * @return int + */ + public function repeat(float $interval, callable $func, array $args = []): int; + + /** + * Delete a repeat timer. + * + * @param int $timerId + * @return bool + */ + public function offRepeat(int $timerId): bool; + + /** + * Execute a callback when a stream resource becomes readable or is closed for reading. + * + * @param resource $stream + * @param callable(resource): void $func + * @return void + */ + public function onReadable($stream, callable $func): void; + + /** + * Cancel a callback of stream readable. + * + * @param resource $stream + * @return bool + */ + public function offReadable($stream): bool; + + /** + * Execute a callback when a stream resource becomes writable or is closed for writing. + * + * @param resource $stream + * @param callable(resource): void $func + * @return void + */ + public function onWritable($stream, callable $func): void; + + /** + * Cancel a callback of stream writable. + * + * @param resource $stream + * @return bool + */ + public function offWritable($stream): bool; + + /** + * Execute a callback when a signal is received. + * + * @param int $signal + * @param callable(int): void $func + * @return void + */ + public function onSignal(int $signal, callable $func): void; + + /** + * Cancel a callback of signal. + * + * @param int $signal + * @return bool + */ + public function offSignal(int $signal): bool; + + /** + * Delete all timer. + * + * @return void + */ + public function deleteAllTimer(): void; + + /** + * Run the event loop. + * + * @return void + */ + public function run(): void; + + /** + * Stop event loop. + * + * @return void + */ + public function stop(): void; + + /** + * Get Timer count. + * + * @return int + */ + public function getTimerCount(): int; + + /** + * Set error handler. + * + * @param callable(\Throwable): void $errorHandler + * @return void + */ + public function setErrorHandler(callable $errorHandler): void; +} diff --git a/vendor/workerman/workerman/src/Events/Fiber.php b/vendor/workerman/workerman/src/Events/Fiber.php new file mode 100644 index 0000000..133f2cc --- /dev/null +++ b/vendor/workerman/workerman/src/Events/Fiber.php @@ -0,0 +1,276 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Events; + +use Fiber as BaseFiber; +use Revolt\EventLoop; +use Revolt\EventLoop\Driver; +use function count; +use function function_exists; +use function pcntl_signal; + +/** + * Revolt eventloop + */ +final class Fiber implements EventInterface +{ + /** + * @var Driver + */ + private Driver $driver; + + /** + * All listeners for read event. + * + * @var array + */ + private array $readEvents = []; + + /** + * All listeners for write event. + * + * @var array + */ + private array $writeEvents = []; + + /** + * Event listeners of signal. + * + * @var array + */ + private array $eventSignal = []; + + /** + * Event listeners of timer. + * + * @var array + */ + private array $eventTimer = []; + + /** + * Timer id. + * + * @var int + */ + private int $timerId = 1; + + /** + * Construct. + */ + public function __construct() + { + $this->driver = EventLoop::getDriver(); + } + + /** + * Get driver. + * + * @return Driver + */ + public function driver(): Driver + { + return $this->driver; + } + + /** + * {@inheritdoc} + */ + public function run(): void + { + $this->driver->run(); + } + + /** + * {@inheritdoc} + */ + public function stop(): void + { + foreach ($this->eventSignal as $cbId) { + $this->driver->cancel($cbId); + } + $this->driver->stop(); + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGINT, SIG_IGN); + } + } + + /** + * {@inheritdoc} + */ + public function delay(float $delay, callable $func, array $args = []): int + { + $timerId = $this->timerId++; + $closure = function () use ($func, $args, $timerId) { + unset($this->eventTimer[$timerId]); + $this->safeCall($func, ...$args); + }; + $cbId = $this->driver->delay($delay, $closure); + $this->eventTimer[$timerId] = $cbId; + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function repeat(float $interval, callable $func, array $args = []): int + { + $timerId = $this->timerId++; + $cbId = $this->driver->repeat($interval, fn() => $this->safeCall($func, ...$args)); + $this->eventTimer[$timerId] = $cbId; + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function onReadable($stream, callable $func): void + { + $fdKey = (int)$stream; + if (isset($this->readEvents[$fdKey])) { + $this->driver->cancel($this->readEvents[$fdKey]); + } + + $this->readEvents[$fdKey] = $this->driver->onReadable($stream, fn() => $this->safeCall($func, $stream)); + } + + /** + * {@inheritdoc} + */ + public function offReadable($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->readEvents[$fdKey])) { + $this->driver->cancel($this->readEvents[$fdKey]); + unset($this->readEvents[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onWritable($stream, callable $func): void + { + $fdKey = (int)$stream; + if (isset($this->writeEvents[$fdKey])) { + $this->driver->cancel($this->writeEvents[$fdKey]); + unset($this->writeEvents[$fdKey]); + } + $this->writeEvents[$fdKey] = $this->driver->onWritable($stream, fn() => $this->safeCall($func, $stream)); + } + + /** + * {@inheritdoc} + */ + public function offWritable($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->writeEvents[$fdKey])) { + $this->driver->cancel($this->writeEvents[$fdKey]); + unset($this->writeEvents[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onSignal(int $signal, callable $func): void + { + $fdKey = $signal; + if (isset($this->eventSignal[$fdKey])) { + $this->driver->cancel($this->eventSignal[$fdKey]); + unset($this->eventSignal[$fdKey]); + } + $this->eventSignal[$fdKey] = $this->driver->onSignal($signal, fn() => $this->safeCall($func, $signal)); + } + + /** + * {@inheritdoc} + */ + public function offSignal(int $signal): bool + { + $fdKey = $signal; + if (isset($this->eventSignal[$fdKey])) { + $this->driver->cancel($this->eventSignal[$fdKey]); + unset($this->eventSignal[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function offDelay(int $timerId): bool + { + if (isset($this->eventTimer[$timerId])) { + $this->driver->cancel($this->eventTimer[$timerId]); + unset($this->eventTimer[$timerId]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function offRepeat(int $timerId): bool + { + return $this->offDelay($timerId); + } + + /** + * {@inheritdoc} + */ + public function deleteAllTimer(): void + { + foreach ($this->eventTimer as $cbId) { + $this->driver->cancel($cbId); + } + $this->eventTimer = []; + } + + /** + * {@inheritdoc} + */ + public function getTimerCount(): int + { + return count($this->eventTimer); + } + + /** + * {@inheritdoc} + */ + public function setErrorHandler(callable $errorHandler): void + { + $this->driver->setErrorHandler($errorHandler); + } + + /** + * @param callable $func + * @param ...$args + * @return void + * @throws \Throwable + */ + protected function safeCall(callable $func, ...$args): void + { + (new BaseFiber(fn() => $func(...$args)))->start(); + } +} diff --git a/vendor/workerman/workerman/src/Events/Select.php b/vendor/workerman/workerman/src/Events/Select.php new file mode 100644 index 0000000..b14b135 --- /dev/null +++ b/vendor/workerman/workerman/src/Events/Select.php @@ -0,0 +1,492 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Events; + +use SplPriorityQueue; +use Throwable; +use function count; +use function max; +use function microtime; +use function pcntl_signal; +use function pcntl_signal_dispatch; +use const DIRECTORY_SEPARATOR; + +/** + * select eventloop + */ +final class Select implements EventInterface +{ + /** + * Running. + * + * @var bool + */ + private bool $running = true; + + /** + * All listeners for read/write event. + * + * @var array + */ + private array $readEvents = []; + + /** + * All listeners for read/write event. + * + * @var array + */ + private array $writeEvents = []; + + /** + * @var array + */ + private array $exceptEvents = []; + + /** + * Event listeners of signal. + * + * @var array + */ + private array $signalEvents = []; + + /** + * Fds waiting for read event. + * + * @var array + */ + private array $readFds = []; + + /** + * Fds waiting for write event. + * + * @var array + */ + private array $writeFds = []; + + /** + * Fds waiting for except event. + * + * @var array + */ + private array $exceptFds = []; + + /** + * Timer scheduler. + * {['data':timer_id, 'priority':run_timestamp], ..} + * + * @var SplPriorityQueue + */ + private SplPriorityQueue $scheduler; + + /** + * All timer event listeners. + * [[func, args, flag, timer_interval], ..] + * + * @var array + */ + private array $eventTimer = []; + + /** + * Timer id. + * + * @var int + */ + private int $timerId = 1; + + /** + * Select timeout. + * + * @var int + */ + private int $selectTimeout = self::MAX_SELECT_TIMOUT_US; + + /** + * Next run time of the timer. + * + * @var float + */ + private float $nextTickTime = 0; + + /** + * @var ?callable + */ + private $errorHandler = null; + + /** + * Select timeout. + * + * @var int + */ + const MAX_SELECT_TIMOUT_US = 800000; + + /** + * Construct. + */ + public function __construct() + { + $this->scheduler = new SplPriorityQueue(); + $this->scheduler->setExtractFlags(SplPriorityQueue::EXTR_BOTH); + } + + /** + * {@inheritdoc} + */ + public function delay(float $delay, callable $func, array $args = []): int + { + $timerId = $this->timerId++; + $runTime = microtime(true) + $delay; + $this->scheduler->insert($timerId, -$runTime); + $this->eventTimer[$timerId] = [$func, $args]; + if ($this->nextTickTime == 0 || $this->nextTickTime > $runTime) { + $this->setNextTickTime($runTime); + } + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function repeat(float $interval, callable $func, array $args = []): int + { + $timerId = $this->timerId++; + $runTime = microtime(true) + $interval; + $this->scheduler->insert($timerId, -$runTime); + $this->eventTimer[$timerId] = [$func, $args, $interval]; + if ($this->nextTickTime == 0 || $this->nextTickTime > $runTime) { + $this->setNextTickTime($runTime); + } + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function offDelay(int $timerId): bool + { + if (isset($this->eventTimer[$timerId])) { + unset($this->eventTimer[$timerId]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function offRepeat(int $timerId): bool + { + return $this->offDelay($timerId); + } + + /** + * {@inheritdoc} + */ + public function onReadable($stream, callable $func): void + { + $count = count($this->readFds); + if ($count >= 1024) { + trigger_error("System call select exceeded the maximum number of connections 1024, please install event extension for more connections.", E_USER_WARNING); + } else if (DIRECTORY_SEPARATOR !== '/' && $count >= 256) { + trigger_error("System call select exceeded the maximum number of connections 256.", E_USER_WARNING); + } + $fdKey = (int)$stream; + $this->readEvents[$fdKey] = $func; + $this->readFds[$fdKey] = $stream; + } + + /** + * {@inheritdoc} + */ + public function offReadable($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->readEvents[$fdKey])) { + unset($this->readEvents[$fdKey], $this->readFds[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onWritable($stream, callable $func): void + { + $count = count($this->writeFds); + if ($count >= 1024) { + trigger_error("System call select exceeded the maximum number of connections 1024, please install event/libevent extension for more connections.", E_USER_WARNING); + } else if (DIRECTORY_SEPARATOR !== '/' && $count >= 256) { + trigger_error("System call select exceeded the maximum number of connections 256.", E_USER_WARNING); + } + $fdKey = (int)$stream; + $this->writeEvents[$fdKey] = $func; + $this->writeFds[$fdKey] = $stream; + } + + /** + * {@inheritdoc} + */ + public function offWritable($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->writeEvents[$fdKey])) { + unset($this->writeEvents[$fdKey], $this->writeFds[$fdKey]); + return true; + } + return false; + } + + /** + * On except. + * + * @param resource $stream + * @param callable $func + */ + public function onExcept($stream, callable $func): void + { + $fdKey = (int)$stream; + $this->exceptEvents[$fdKey] = $func; + $this->exceptFds[$fdKey] = $stream; + } + + /** + * Off except. + * + * @param resource $stream + * @return bool + */ + public function offExcept($stream): bool + { + $fdKey = (int)$stream; + if (isset($this->exceptEvents[$fdKey])) { + unset($this->exceptEvents[$fdKey], $this->exceptFds[$fdKey]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onSignal(int $signal, callable $func): void + { + if (!function_exists('pcntl_signal')) { + return; + } + $this->signalEvents[$signal] = $func; + pcntl_signal($signal, fn () => $this->safeCall($this->signalEvents[$signal], [$signal])); + } + + /** + * {@inheritdoc} + */ + public function offSignal(int $signal): bool + { + if (!function_exists('pcntl_signal')) { + return false; + } + pcntl_signal($signal, SIG_IGN); + if (isset($this->signalEvents[$signal])) { + unset($this->signalEvents[$signal]); + return true; + } + return false; + } + + /** + * Tick for timer. + * + * @return void + */ + protected function tick(): void + { + $tasksToInsert = []; + while (!$this->scheduler->isEmpty()) { + $schedulerData = $this->scheduler->top(); + $timerId = $schedulerData['data']; + $nextRunTime = -$schedulerData['priority']; + $timeNow = microtime(true); + $this->selectTimeout = (int)(($nextRunTime - $timeNow) * 1000000); + + if ($this->selectTimeout <= 0) { + $this->scheduler->extract(); + + if (!isset($this->eventTimer[$timerId])) { + continue; + } + + // [func, args, timer_interval] + $taskData = $this->eventTimer[$timerId]; + if (isset($taskData[2])) { + $nextRunTime = $timeNow + $taskData[2]; + $tasksToInsert[] = [$timerId, -$nextRunTime]; + } else { + unset($this->eventTimer[$timerId]); + } + $this->safeCall($taskData[0], $taskData[1]); + } else { + break; + } + } + foreach ($tasksToInsert as $item) { + $this->scheduler->insert($item[0], $item[1]); + } + if (!$this->scheduler->isEmpty()) { + $schedulerData = $this->scheduler->top(); + $nextRunTime = -$schedulerData['priority']; + $this->setNextTickTime($nextRunTime); + return; + } + $this->setNextTickTime(0); + } + + /** + * Set next tick time. + * + * @param float $nextTickTime + * @return void + */ + protected function setNextTickTime(float $nextTickTime): void + { + $this->nextTickTime = $nextTickTime; + if ($nextTickTime == 0) { + $this->selectTimeout = self::MAX_SELECT_TIMOUT_US; + return; + } + $this->selectTimeout = min(max((int)(($nextTickTime - microtime(true)) * 1000000), 0), self::MAX_SELECT_TIMOUT_US); + } + + /** + * {@inheritdoc} + */ + public function deleteAllTimer(): void + { + $this->scheduler = new SplPriorityQueue(); + $this->scheduler->setExtractFlags(SplPriorityQueue::EXTR_BOTH); + $this->eventTimer = []; + } + + /** + * {@inheritdoc} + */ + public function run(): void + { + while ($this->running) { + $read = $this->readFds; + $write = $this->writeFds; + $except = $this->exceptFds; + if ($read || $write || $except) { + // Waiting read/write/signal/timeout events. + try { + @stream_select($read, $write, $except, 0, $this->selectTimeout); + } catch (Throwable) { + // do nothing + } + } else { + $this->selectTimeout >= 1 && usleep($this->selectTimeout); + } + + foreach ($read as $fd) { + $fdKey = (int)$fd; + if (isset($this->readEvents[$fdKey])) { + $this->readEvents[$fdKey]($fd); + } + } + + foreach ($write as $fd) { + $fdKey = (int)$fd; + if (isset($this->writeEvents[$fdKey])) { + $this->writeEvents[$fdKey]($fd); + } + } + + foreach ($except as $fd) { + $fdKey = (int)$fd; + if (isset($this->exceptEvents[$fdKey])) { + $this->exceptEvents[$fdKey]($fd); + } + } + + if ($this->nextTickTime > 0) { + if (microtime(true) >= $this->nextTickTime) { + $this->tick(); + } else { + $this->selectTimeout = (int)(($this->nextTickTime - microtime(true)) * 1000000); + } + } + + // The $this->signalEvents are empty under Windows, make sure not to call pcntl_signal_dispatch. + if ($this->signalEvents) { + // Calls signal handlers for pending signals + pcntl_signal_dispatch(); + } + } + } + + /** + * {@inheritdoc} + */ + public function stop(): void + { + $this->running = false; + $this->deleteAllTimer(); + foreach ($this->signalEvents as $signal => $item) { + $this->offsignal($signal); + } + $this->readFds = []; + $this->writeFds = []; + $this->exceptFds = []; + $this->readEvents = []; + $this->writeEvents = []; + $this->exceptEvents = []; + $this->signalEvents = []; + } + + /** + * {@inheritdoc} + */ + public function getTimerCount(): int + { + return count($this->eventTimer); + } + + /** + * {@inheritdoc} + */ + public function setErrorHandler(callable $errorHandler): void + { + $this->errorHandler = $errorHandler; + } + + /** + * @param callable $func + * @param array $args + * @return void + */ + private function safeCall(callable $func, array $args = []): void + { + try { + $func(...$args); + } catch (Throwable $e) { + if ($this->errorHandler === null) { + echo $e; + } else { + ($this->errorHandler)($e); + } + } + } +} diff --git a/vendor/workerman/workerman/src/Events/Swoole.php b/vendor/workerman/workerman/src/Events/Swoole.php new file mode 100644 index 0000000..329c87e --- /dev/null +++ b/vendor/workerman/workerman/src/Events/Swoole.php @@ -0,0 +1,298 @@ + + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Events; + +use Swoole\Coroutine; +use Swoole\Event; +use Swoole\Process; +use Swoole\Timer; +use Throwable; + +final class Swoole implements EventInterface +{ + /** + * All listeners for read timer + * + * @var array + */ + private array $eventTimer = []; + + /** + * All listeners for read event. + * + * @var array + */ + private array $readEvents = []; + + /** + * All listeners for write event. + * + * @var array + */ + private array $writeEvents = []; + + /** + * @var ?callable + */ + private $errorHandler = null; + + private bool $stopping = false; + + /** + * Constructor. + */ + public function __construct() + { + Coroutine::set(['hook_flags' => SWOOLE_HOOK_ALL]); + } + + /** + * {@inheritdoc} + */ + public function delay(float $delay, callable $func, array $args = []): int + { + $t = (int)($delay * 1000); + $t = max($t, 1); + $timerId = Timer::after($t, function () use ($func, $args, &$timerId) { + unset($this->eventTimer[$timerId]); + $this->safeCall($func, $args); + }); + $this->eventTimer[$timerId] = $timerId; + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function offDelay(int $timerId): bool + { + if (isset($this->eventTimer[$timerId])) { + Timer::clear($timerId); + unset($this->eventTimer[$timerId]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function offRepeat(int $timerId): bool + { + return $this->offDelay($timerId); + } + + /** + * {@inheritdoc} + */ + public function repeat(float $interval, callable $func, array $args = []): int + { + $t = (int)($interval * 1000); + $t = max($t, 1); + $timerId = Timer::tick($t, function () use ($func, $args) { + $this->safeCall($func, $args); + }); + $this->eventTimer[$timerId] = $timerId; + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function onReadable($stream, callable $func): void + { + $fd = (int)$stream; + if (!isset($this->readEvents[$fd]) && !isset($this->writeEvents[$fd])) { + Event::add($stream, fn () => $this->callRead($fd), null, SWOOLE_EVENT_READ); + } elseif (isset($this->writeEvents[$fd])) { + Event::set($stream, fn () => $this->callRead($fd), null, SWOOLE_EVENT_READ | SWOOLE_EVENT_WRITE); + } else { + Event::set($stream, fn () => $this->callRead($fd), null, SWOOLE_EVENT_READ); + } + + $this->readEvents[$fd] = [$func, [$stream]]; + } + + /** + * {@inheritdoc} + */ + public function offReadable($stream): bool + { + $fd = (int)$stream; + if (!isset($this->readEvents[$fd])) { + return false; + } + unset($this->readEvents[$fd]); + if (!isset($this->writeEvents[$fd])) { + Event::del($stream); + return true; + } + Event::set($stream, null, null, SWOOLE_EVENT_WRITE); + return true; + } + + /** + * {@inheritdoc} + */ + public function onWritable($stream, callable $func): void + { + $fd = (int)$stream; + if (!isset($this->readEvents[$fd]) && !isset($this->writeEvents[$fd])) { + Event::add($stream, null, fn () => $this->callWrite($fd), SWOOLE_EVENT_WRITE); + } elseif (isset($this->readEvents[$fd])) { + Event::set($stream, null, fn () => $this->callWrite($fd), SWOOLE_EVENT_WRITE | SWOOLE_EVENT_READ); + } else { + Event::set($stream, null, fn () =>$this->callWrite($fd), SWOOLE_EVENT_WRITE); + } + + $this->writeEvents[$fd] = [$func, [$stream]]; + } + + /** + * {@inheritdoc} + */ + public function offWritable($stream): bool + { + $fd = (int)$stream; + if (!isset($this->writeEvents[$fd])) { + return false; + } + unset($this->writeEvents[$fd]); + if (!isset($this->readEvents[$fd])) { + Event::del($stream); + return true; + } + Event::set($stream, null, null, SWOOLE_EVENT_READ); + return true; + } + + /** + * {@inheritdoc} + */ + public function onSignal(int $signal, callable $func): void + { + Process::signal($signal, fn () => $this->safeCall($func, [$signal])); + } + + /** + * Please see https://wiki.swoole.com/#/process/process?id=signal + * {@inheritdoc} + */ + public function offSignal(int $signal): bool + { + return Process::signal($signal, null); + } + + /** + * {@inheritdoc} + */ + public function deleteAllTimer(): void + { + foreach ($this->eventTimer as $timerId) { + Timer::clear($timerId); + } + } + + /** + * {@inheritdoc} + */ + public function run(): void + { + // Avoid process exit due to no listening + Timer::tick(100000000, static fn() => null); + Event::wait(); + } + + /** + * Destroy loop. + * + * @return void + */ + public function stop(): void + { + if ($this->stopping) { + return; + } + $this->stopping = true; + // Cancel all coroutines before Event::exit + foreach (Coroutine::listCoroutines() as $coroutine) { + Coroutine::cancel($coroutine); + } + // Wait for coroutines to exit + usleep(200000); + Event::exit(); + } + + /** + * Get timer count. + * + * @return integer + */ + public function getTimerCount(): int + { + return count($this->eventTimer); + } + + /** + * {@inheritdoc} + */ + public function setErrorHandler(callable $errorHandler): void + { + $this->errorHandler = $errorHandler; + } + + /** + * @param $fd + * @return void + */ + private function callRead($fd) + { + if (isset($this->readEvents[$fd])) { + $this->safeCall($this->readEvents[$fd][0], $this->readEvents[$fd][1]); + } + } + + /** + * @param $fd + * @return void + */ + private function callWrite($fd) + { + if (isset($this->writeEvents[$fd])) { + $this->safeCall($this->writeEvents[$fd][0], $this->writeEvents[$fd][1]); + } + } + + /** + * @param callable $func + * @param array $args + * @return void + */ + private function safeCall(callable $func, array $args = []): void + { + Coroutine::create(function() use ($func, $args) { + try { + $func(...$args); + } catch (Throwable $e) { + if ($this->errorHandler === null) { + echo $e; + } else { + ($this->errorHandler)($e); + } + } + }); + } +} diff --git a/vendor/workerman/workerman/src/Events/Swow.php b/vendor/workerman/workerman/src/Events/Swow.php new file mode 100644 index 0000000..45c5210 --- /dev/null +++ b/vendor/workerman/workerman/src/Events/Swow.php @@ -0,0 +1,307 @@ + + */ + private array $eventTimer = []; + + /** + * All listeners for read event. + * + * @var array + */ + private array $readEvents = []; + + /** + * All listeners for write event. + * + * @var array + */ + private array $writeEvents = []; + + /** + * All listeners for signal. + * + * @var array + */ + private array $signalListener = []; + + /** + * @var ?callable + */ + private $errorHandler = null; + + /** + * Get timer count. + * + * @return integer + */ + public function getTimerCount(): int + { + return count($this->eventTimer); + } + + /** + * {@inheritdoc} + */ + public function delay(float $delay, callable $func, array $args = []): int + { + $t = (int)($delay * 1000); + $t = max($t, 1); + $coroutine = Coroutine::run(function () use ($t, $func, $args): void { + msleep($t); + unset($this->eventTimer[Coroutine::getCurrent()->getId()]); + $this->safeCall($func, $args); + }); + $timerId = $coroutine->getId(); + $this->eventTimer[$timerId] = $timerId; + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function repeat(float $interval, callable $func, array $args = []): int + { + $t = (int)($interval * 1000); + $t = max($t, 1); + $coroutine = Coroutine::run(function () use ($t, $func, $args): void { + // @phpstan-ignore-next-line While loop condition is always true. + while (true) { + msleep($t); + $this->safeCall($func, $args); + } + }); + $timerId = $coroutine->getId(); + $this->eventTimer[$timerId] = $timerId; + return $timerId; + } + + /** + * {@inheritdoc} + */ + public function offDelay(int $timerId): bool + { + if (isset($this->eventTimer[$timerId])) { + try { + (Coroutine::getAll()[$timerId])->kill(); + return true; + } finally { + unset($this->eventTimer[$timerId]); + } + } + return false; + } + + /** + * {@inheritdoc} + */ + public function offRepeat(int $timerId): bool + { + return $this->offDelay($timerId); + } + + /** + * {@inheritdoc} + */ + public function deleteAllTimer(): void + { + foreach ($this->eventTimer as $timerId) { + $this->offDelay($timerId); + } + } + + /** + * {@inheritdoc} + */ + public function onReadable($stream, callable $func): void + { + $fd = (int)$stream; + if (isset($this->readEvents[$fd])) { + $this->offReadable($stream); + } + Coroutine::run(function () use ($stream, $func, $fd): void { + try { + $this->readEvents[$fd] = Coroutine::getCurrent(); + while (true) { + if (!is_resource($stream)) { + $this->offReadable($stream); + break; + } + // Under Windows, setting a timeout is necessary; otherwise, the accept cannot be listened to. + // Setting it to 1000ms will result in a 1-second delay for the first accept under Windows. + if (!isset($this->readEvents[$fd]) || $this->readEvents[$fd] !== Coroutine::getCurrent()) { + break; + } + $rEvent = stream_poll_one($stream, STREAM_POLLIN | STREAM_POLLHUP, 1000); + if ($rEvent !== STREAM_POLLNONE) { + $this->safeCall($func, [$stream]); + } + if ($rEvent !== STREAM_POLLIN && $rEvent !== STREAM_POLLNONE) { + $this->offReadable($stream); + break; + } + } + } catch (RuntimeException) { + $this->offReadable($stream); + } + }); + } + + /** + * {@inheritdoc} + */ + public function offReadable($stream): bool + { + // 在当前协程执行 $coroutine->kill() 会导致不可预知问题,所以没有使用$coroutine->kill() + $fd = (int)$stream; + if (isset($this->readEvents[$fd])) { + unset($this->readEvents[$fd]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onWritable($stream, callable $func): void + { + $fd = (int)$stream; + if (isset($this->writeEvents[$fd])) { + $this->offWritable($stream); + } + Coroutine::run(function () use ($stream, $func, $fd): void { + try { + $this->writeEvents[$fd] = Coroutine::getCurrent(); + while (true) { + if (!is_resource($stream)) { + $this->offWritable($stream); + break; + } + if (!isset($this->writeEvents[$fd]) || $this->writeEvents[$fd] !== Coroutine::getCurrent()) { + break; + } + $rEvent = stream_poll_one($stream, STREAM_POLLOUT | STREAM_POLLHUP, 1000); + if ($rEvent !== STREAM_POLLNONE) { + $this->safeCall($func, [$stream]); + } + if ($rEvent !== STREAM_POLLOUT && $rEvent !== STREAM_POLLNONE) { + $this->offWritable($stream); + break; + } + } + } catch (RuntimeException) { + $this->offWritable($stream); + } + }); + } + + /** + * {@inheritdoc} + */ + public function offWritable($stream): bool + { + $fd = (int)$stream; + if (isset($this->writeEvents[$fd])) { + unset($this->writeEvents[$fd]); + return true; + } + return false; + } + + /** + * {@inheritdoc} + */ + public function onSignal(int $signal, callable $func): void + { + Coroutine::run(function () use ($signal, $func): void { + $this->signalListener[$signal] = Coroutine::getCurrent(); + while (1) { + try { + Signal::wait($signal); + if (!isset($this->signalListener[$signal]) || + $this->signalListener[$signal] !== Coroutine::getCurrent()) { + break; + } + $this->safeCall($func, [$signal]); + } catch (SignalException) { + // do nothing + } + } + }); + } + + /** + * {@inheritdoc} + */ + public function offSignal(int $signal): bool + { + if (!isset($this->signalListener[$signal])) { + return false; + } + unset($this->signalListener[$signal]); + return true; + } + + /** + * {@inheritdoc} + */ + public function run(): void + { + waitAll(); + } + + /** + * Destroy loop. + * + * @return void + */ + public function stop(): void + { + Coroutine::killAll(); + } + + /** + * {@inheritdoc} + */ + public function setErrorHandler(callable $errorHandler): void + { + $this->errorHandler = $errorHandler; + } + + /** + * @param callable $func + * @param array $args + * @return void + */ + private function safeCall(callable $func, array $args = []): void + { + Coroutine::run(function () use ($func, $args): void { + try { + $func(...$args); + } catch (\Throwable $e) { + if ($this->errorHandler === null) { + echo $e; + } else { + ($this->errorHandler)($e); + } + } + }); + } + +} diff --git a/vendor/workerman/workerman/src/Protocols/Frame.php b/vendor/workerman/workerman/src/Protocols/Frame.php new file mode 100644 index 0000000..c8ce71c --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Frame.php @@ -0,0 +1,66 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols; + +use function pack; +use function strlen; +use function substr; +use function unpack; + +/** + * Frame Protocol. + */ +class Frame +{ + /** + * Check the integrity of the package. + * + * @param string $buffer + * @return int + */ + public static function input(string $buffer): int + { + if (strlen($buffer) < 4) { + return 0; + } + $unpackData = unpack('Ntotal_length', $buffer); + return $unpackData['total_length']; + } + + /** + * Decode. + * + * @param string $buffer + * @return string + */ + public static function decode(string $buffer): string + { + return substr($buffer, 4); + } + + /** + * Encode. + * + * @param string $data + * @return string + */ + public static function encode(string $data): string + { + $totalLength = 4 + strlen($data); + return pack('N', $totalLength) . $data; + } +} diff --git a/vendor/workerman/workerman/src/Protocols/Http.php b/vendor/workerman/workerman/src/Protocols/Http.php new file mode 100644 index 0000000..8398c55 --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http.php @@ -0,0 +1,507 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols; + +use Workerman\Connection\TcpConnection; +use Workerman\Protocols\Http\Request; +use Workerman\Protocols\Http\Response; +use function clearstatcache; +use function count; +use function ctype_digit; +use function ctype_xdigit; +use function explode; +use function filesize; +use function fopen; +use function fread; +use function fseek; +use function ftell; +use function hexdec; +use function ini_get; +use function is_array; +use function is_object; +use function ltrim; +use function preg_match; +use function preg_replace; +use function strlen; +use function strpos; +use function strtolower; +use function substr; +use function sys_get_temp_dir; +use function trim; + +/** + * Class Http. + * @package Workerman\Protocols + */ +class Http +{ + /** + * Request class name. + * + * @var string + */ + protected static string $requestClass = Request::class; + + /** + * Upload tmp dir. + * + * @var string + */ + protected static string $uploadTmpDir = ''; + + /** + * Bad request. + * + * @var string + */ + protected const HTTP_400 = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"; + + /** + * Payload too large. + * + * @var string + */ + protected const HTTP_413 = "HTTP/1.1 413 Payload Too Large\r\nConnection: close\r\n\r\n"; + + /** + * Max bytes buffered while waiting for end of headers, and max offset of "\r\n\r\n" (header block size limit). + */ + protected const MAX_HEADER_LENGTH = 16384; + + /** + * Get or set the request class name. + * + * @param class-string|null $className + * @return string + */ + public static function requestClass(?string $className = null): string + { + if ($className !== null) { + static::$requestClass = $className; + } + return static::$requestClass; + } + + /** + * Check the integrity of the package. + * + * @param string $buffer + * @param TcpConnection $connection + * @return int + */ + public static function input(string $buffer, TcpConnection $connection): int + { + static $cache = []; + + $crlfPos = strpos($buffer, "\r\n\r\n"); + if (false === $crlfPos) { + if (strlen($buffer) >= static::MAX_HEADER_LENGTH) { + $connection->end(static::HTTP_413, true); + } + return 0; + } + + $length = $crlfPos + 4; + if ($crlfPos >= static::MAX_HEADER_LENGTH) { + $connection->end(static::HTTP_413, true); + return 0; + } + $header = isset($buffer[$length]) ? substr($buffer, 0, $length) : $buffer; + + if ($length <= TcpConnection::MAX_CACHE_STRING_LENGTH && isset($cache[$header])) { + return $cache[$header]; + } + + // Validate request line: METHOD SP origin-form SP HTTP/1.x + $firstLineEnd = strpos($header, "\r\n"); + if (!preg_match( + '~^(?-i:GET|POST|OPTIONS|HEAD|DELETE|PUT|PATCH) /[^\x00-\x20\x7f]* (?-i:HTTP)/1\.(?[01])$~', + substr($header, 0, $firstLineEnd), + $matches + )) { + $connection->end(static::HTTP_400, true); + return 0; + } + + // Parse headers + $headers = []; + $headerBody = substr($header, $firstLineEnd + 2, $crlfPos - $firstLineEnd - 2); + foreach (explode("\r\n", $headerBody) as $line) { + if ($line === '') { + continue; + } + $parts = explode(':', $line, 2); + // field-name must be a token: 1*tchar (RFC 7230 §3.2.6) + if (!isset($parts[1]) || !preg_match('/^[a-zA-Z0-9!#$%&\'*+\-.^_`|~]+$/', $parts[0])) { + $connection->end(static::HTTP_400, true); + return 0; + } + $headers[strtolower($parts[0])][] = trim($parts[1], " \t"); + } + + // Host: required for HTTP/1.1, must not be duplicated for any version (RFC 7230 §5.4) + $hostCount = count($headers['host'] ?? []); + if ($hostCount > 1 || ($matches['minor'] === '1' && $hostCount === 0)) { + $connection->end(static::HTTP_400, true); + return 0; + } + + // Transfer-Encoding: must be sole header with value "chunked", no Content-Length + if (isset($headers['transfer-encoding'])) { + if (isset($headers['content-length']) + || count($headers['transfer-encoding']) !== 1 + || strtolower($headers['transfer-encoding'][0]) !== 'chunked') { + $connection->end(static::HTTP_400, true); + return 0; + } + return static::inputChunked($buffer, $connection, $length); + } + + // Content-Length: must be single header with pure-digit value + if (isset($headers['content-length'])) { + if (count($headers['content-length']) !== 1 || !ctype_digit($headers['content-length'][0])) { + $connection->end(static::HTTP_400, true); + return 0; + } + $length += (int)$headers['content-length'][0]; + } + + if ($length > $connection->maxPackageSize) { + $connection->end(static::HTTP_413, true); + return 0; + } + + if ($length <= TcpConnection::MAX_CACHE_STRING_LENGTH) { + $cache[$header] = $length; + if (count($cache) > TcpConnection::MAX_CACHE_SIZE) { + unset($cache[key($cache)]); + } + } + return $length; + } + + + /** + * Check the integrity of a chunked transfer-encoded request body. + * + * @param string $buffer + * @param TcpConnection $connection + * @param int $headerLength + * @return int + */ + protected static function inputChunked(string $buffer, TcpConnection $connection, int $headerLength): int + { + $connection->context ??= new \stdClass(); + $connection->context->chunked = true; + + $pos = $headerLength; + $bufLen = strlen($buffer); + $maxSize = $connection->maxPackageSize; + + while (true) { + $lineEnd = strpos($buffer, "\r\n", $pos); + if ($lineEnd === false) { + return 0; + } + + $semiPos = strpos($buffer, ';', $pos); + $hexEnd = ($semiPos !== false && $semiPos < $lineEnd) ? $semiPos : $lineEnd; + $hexStr = substr($buffer, $pos, $hexEnd - $pos); + + if ($hexStr === '' || !ctype_xdigit($hexStr) || isset($hexStr[16])) { + $connection->end(static::HTTP_400, true); + return 0; + } + + $chunkSize = hexdec($hexStr); + if (is_float($chunkSize)) { + $connection->end(static::HTTP_400, true); + return 0; + } + $pos = $lineEnd + 2; + + if ($chunkSize === 0) { + while (true) { + $lineEnd = strpos($buffer, "\r\n", $pos); + if ($lineEnd === false) { + return 0; + } + if ($lineEnd === $pos) { + $totalLength = $pos + 2; + if ($totalLength > $maxSize) { + $connection->end(static::HTTP_413, true); + return 0; + } + return $totalLength; + } + $pos = $lineEnd + 2; + } + } + + if ($pos + $chunkSize + 2 > $bufLen) { + return 0; + } + if (substr($buffer, $pos + $chunkSize, 2) !== "\r\n") { + $connection->end(static::HTTP_400, true); + return 0; + } + $pos += $chunkSize + 2; + + if ($pos > $maxSize) { + $connection->end(static::HTTP_413, true); + return 0; + } + } + } + + /** + * Http decode. + * + * @param string $buffer + * @param TcpConnection $connection + * @return mixed + */ + public static function decode(string $buffer, TcpConnection $connection): mixed + { + $trailers = []; + if (isset($connection->context->chunked)) { + unset($connection->context->chunked); + [$buffer, $trailers] = static::decodeChunked($buffer, strpos($buffer, "\r\n\r\n")); + } + + $request = new static::$requestClass($buffer); + if ($trailers !== []) { + $request->setChunkTrailers($trailers); + } + $request->connection = $connection; + return $request; + } + + /** + * Decode a chunked transfer-encoded request into a normalized buffer. + * + * @param string $buffer + * @param int $headerEnd + * @return array{string, array} + */ + protected static function decodeChunked(string $buffer, int $headerEnd): array + { + $header = preg_replace('~\r\nTransfer-Encoding[ \t]*:[^\r]*~i', '', substr($buffer, 0, $headerEnd), 1); + $body = ''; + $trailers = []; + $pos = $headerEnd + 4; + $bufLen = strlen($buffer); + + while (true) { + $lineEnd = strpos($buffer, "\r\n", $pos); + if ($lineEnd === false) { + break; + } + + $semiPos = strpos($buffer, ';', $pos); + $hexEnd = ($semiPos !== false && $semiPos < $lineEnd) ? $semiPos : $lineEnd; + $hexStr = substr($buffer, $pos, $hexEnd - $pos); + if ($hexStr === '' || !ctype_xdigit($hexStr) || isset($hexStr[16])) { + break; + } + + $chunkSize = hexdec($hexStr); + if (is_float($chunkSize)) { + break; + } + $pos = $lineEnd + 2; + + if ($chunkSize === 0) { + while (true) { + $lineEnd = strpos($buffer, "\r\n", $pos); + if ($lineEnd === false) { + break 2; + } + if ($lineEnd === $pos) { + $pos += 2; + break; + } + $colonPos = strpos($buffer, ':', $pos); + if ($colonPos !== false && $colonPos < $lineEnd) { + $trailers[strtolower(substr($buffer, $pos, $colonPos - $pos))] = ltrim(substr($buffer, $colonPos + 1, $lineEnd - $colonPos - 1)); + } + $pos = $lineEnd + 2; + } + break; + } + + if ($pos + $chunkSize + 2 > $bufLen) { + break; + } + if (substr($buffer, $pos + $chunkSize, 2) !== "\r\n") { + break; + } + $body .= substr($buffer, $pos, $chunkSize); + $pos += $chunkSize + 2; + } + + return [$header . "\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body, $trailers]; + } + + /** + * Http encode. + * + * @param string|Response $response + * @param TcpConnection $connection + * @return string + */ + public static function encode(mixed $response, TcpConnection $connection): string + { + if (!is_object($response)) { + $extHeader = ''; + $contentType = 'text/html;charset=utf-8'; + foreach ($connection->headers as $name => $value) { + if ($name === 'Content-Type') { + $contentType = $value; + continue; + } + if (is_array($value)) { + foreach ($value as $item) { + $extHeader .= "$name: $item\r\n"; + } + } else { + $extHeader .= "$name: $value\r\n"; + } + } + $connection->headers = []; + $response = (string)$response; + $bodyLen = strlen($response); + return "HTTP/1.1 200 OK\r\n{$extHeader}Connection: keep-alive\r\nContent-Type: $contentType\r\nContent-Length: $bodyLen\r\n\r\n$response"; + } + + if ($connection->headers) { + $response->withHeaders($connection->headers); + $connection->headers = []; + } + + if (isset($response->file)) { + $file = $response->file['file']; + $offset = $response->file['offset'] ?: 0; + $length = $response->file['length'] ?: 0; + clearstatcache(); + $fileSize = (int)filesize($file); + $bodyLen = $length > 0 ? $length : $fileSize - $offset; + $response->withHeaders([ + 'Content-Length' => $bodyLen, + 'Accept-Ranges' => 'bytes', + ]); + + if ($offset || $length) { + $offsetEnd = $offset + $bodyLen - 1; + $response->header('Content-Range', "bytes $offset-$offsetEnd/$fileSize"); + $response->withStatus(206); + } + if ($bodyLen < 2 * 1024 * 1024) { + $connection->send($response . file_get_contents($file, false, null, $offset, $bodyLen), true); + return ''; + } + $handler = fopen($file, 'r'); + if (false === $handler) { + $connection->close(new Response(403, [], '403 Forbidden')); + return ''; + } + $connection->send((string)$response, true); + static::sendStream($connection, $handler, $offset, $length); + return ''; + } + + return (string)$response; + } + + /** + * Send remainder of a stream to client. + * + * @param TcpConnection $connection + * @param resource $handler + * @param int $offset + * @param int $length + */ + protected static function sendStream(TcpConnection $connection, $handler, int $offset = 0, int $length = 0): void + { + $connection->context->bufferFull = false; + $connection->context->streamSending = true; + if ($offset !== 0) { + fseek($handler, $offset); + } + $offsetEnd = $offset + $length; + // Read file content from disk piece by piece and send to client. + $doWrite = function () use ($connection, $handler, $length, $offsetEnd) { + // Send buffer not full. + /** @phpstan-ignore-next-line */ + while ($connection->context->bufferFull === false) { + // Read from disk. + $size = 1024 * 1024; + if ($length !== 0) { + $tell = ftell($handler); + $remainSize = $offsetEnd - $tell; + if ($remainSize <= 0) { + fclose($handler); + $connection->onBufferDrain = null; + return; + } + $size = min($remainSize, $size); + } + + $buffer = fread($handler, $size); + // Read eof. + if ($buffer === '' || $buffer === false) { + fclose($handler); + $connection->onBufferDrain = null; + $connection->context->streamSending = false; + return; + } + $connection->send($buffer, true); + } + }; + // Send buffer full. + $connection->onBufferFull = function ($connection) { + $connection->context->bufferFull = true; + }; + // Send buffer drain. + $connection->onBufferDrain = function ($connection) use ($doWrite) { + $connection->context->bufferFull = false; + $doWrite(); + }; + $doWrite(); + } + + /** + * Set or get uploadTmpDir. + * + * @param string|null $dir + * @return string + */ + public static function uploadTmpDir(string|null $dir = null): string + { + if (null !== $dir) { + static::$uploadTmpDir = $dir; + } + if (static::$uploadTmpDir === '') { + if ($uploadTmpDir = ini_get('upload_tmp_dir')) { + static::$uploadTmpDir = $uploadTmpDir; + } else if ($uploadTmpDir = sys_get_temp_dir()) { + static::$uploadTmpDir = $uploadTmpDir; + } + } + return static::$uploadTmpDir; + } +} diff --git a/vendor/workerman/workerman/src/Protocols/Http/Chunk.php b/vendor/workerman/workerman/src/Protocols/Http/Chunk.php new file mode 100644 index 0000000..2c7a2a8 --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/Chunk.php @@ -0,0 +1,37 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http; + +use Stringable; + +use function dechex; +use function strlen; + +/** + * Class Chunk + * @package Workerman\Protocols\Http + */ +class Chunk implements Stringable +{ + + public function __construct(protected string $buffer) {} + + public function __toString(): string + { + return dechex(strlen($this->buffer)) . "\r\n$this->buffer\r\n"; + } +} diff --git a/vendor/workerman/workerman/src/Protocols/Http/Request.php b/vendor/workerman/workerman/src/Protocols/Http/Request.php new file mode 100644 index 0000000..4a0c8a9 --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/Request.php @@ -0,0 +1,846 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http; + +use Exception; +use RuntimeException; +use Stringable; +use Workerman\Connection\TcpConnection; +use Workerman\Protocols\Http; +use function array_walk_recursive; +use function bin2hex; +use function clearstatcache; +use function count; +use function explode; +use function file_put_contents; +use function is_file; +use function json_decode; +use function ltrim; +use function microtime; +use function pack; +use function parse_str; +use function parse_url; +use function preg_match; +use function preg_replace; +use function strlen; +use function strpos; +use function strstr; +use function strtolower; +use function substr; +use function tempnam; +use function trim; +use function unlink; +use function urlencode; + +/** + * Class Request + * @package Workerman\Protocols\Http + */ +class Request implements Stringable +{ + /** + * Connection. + * + * @var ?TcpConnection + */ + public ?TcpConnection $connection = null; + + /** + * @var int + */ + public static int $maxFileUploads = 1024; + + /** + * Maximum string length for cache + * + * @var int + */ + public const MAX_CACHE_STRING_LENGTH = 4096; + + /** + * Maximum cache size. + * + * @var int + */ + public const MAX_CACHE_SIZE = 256; + + /** + * Properties. + * + * @var array + */ + public array $properties = []; + + /** + * Request data. + * + * @var array + */ + protected array $data = []; + + /** + * Is safe. + * + * @var bool + */ + protected bool $isSafe = true; + + /** + * Context. + * + * @var array + */ + public array $context = []; + + /** + * HTTP/1.1 chunked trailers (field names lowercased), set once by the protocol layer. + * + * @var ?array + */ + protected ?array $chunkTrailers = null; + + /** + * Request constructor. + */ + public function __construct(protected string $buffer) {} + + /** + * @internal Set by {@see Http::decode()} for chunked requests; first call wins. + * + * @param array $trailers + * @return void + */ + public function setChunkTrailers(array $trailers): void + { + if ($this->chunkTrailers !== null) { + return; + } + $this->chunkTrailers = $trailers; + } + + /** + * Get query. + * + * @param string|null $name + * @param mixed $default + * @return mixed + */ + public function get(?string $name = null, mixed $default = null): mixed + { + if (!isset($this->data['get'])) { + $this->parseGet(); + } + if (null === $name) { + return $this->data['get']; + } + return $this->data['get'][$name] ?? $default; + } + + /** + * Get post. + * + * @param string|null $name + * @param mixed $default + * @return mixed + */ + public function post(?string $name = null, mixed $default = null): mixed + { + if (!isset($this->data['post'])) { + $this->parsePost(); + } + if (null === $name) { + return $this->data['post']; + } + return $this->data['post'][$name] ?? $default; + } + + /** + * Get header item by name. + * + * @param string|null $name + * @param mixed $default + * @return mixed + */ + public function header(?string $name = null, mixed $default = null): mixed + { + if (!isset($this->data['headers'])) { + $this->parseHeaders(); + } + if (null === $name) { + return $this->data['headers']; + } + $name = strtolower($name); + return $this->data['headers'][$name] ?? $default; + } + + /** + * Get trailer item by name. + * + * @param string|null $name + * @param mixed $default + * @return mixed + */ + public function trailer(?string $name = null, mixed $default = null): mixed + { + $all = $this->chunkTrailers ?? []; + if (null === $name) { + return $all; + } + return $all[strtolower($name)] ?? $default; + } + + /** + * Get cookie item by name. + * + * @param string|null $name + * @param mixed $default + * @return mixed + */ + public function cookie(?string $name = null, mixed $default = null): mixed + { + if (!isset($this->data['cookie'])) { + $cookies = explode(';', $this->header('cookie', '')); + $mapped = []; + + foreach ($cookies as $cookie) { + $cookie = explode('=', $cookie, 2); + if (count($cookie) !== 2) { + continue; + } + $mapped[trim($cookie[0])] = $cookie[1]; + } + $this->data['cookie'] = $mapped; + } + if ($name === null) { + return $this->data['cookie']; + } + return $this->data['cookie'][$name] ?? $default; + } + + /** + * Get upload files. + * + * @param string|null $name + * @return array|null + */ + public function file(?string $name = null): mixed + { + clearstatcache(); + if (!empty($this->data['files'])) { + array_walk_recursive($this->data['files'], function ($value, $key) { + if ($key === 'tmp_name' && !is_file($value)) { + $this->data['files'] = []; + } + }); + } + if (empty($this->data['files'])) { + $this->parsePost(); + } + if (null === $name) { + return $this->data['files']; + } + return $this->data['files'][$name] ?? null; + } + + /** + * Get method. + * + * @return string + */ + public function method(): string + { + if (!isset($this->data['method'])) { + $this->parseHeadFirstLine(); + } + return $this->data['method']; + } + + /** + * Get http protocol version. + * + * @return string + */ + public function protocolVersion(): string + { + if (!isset($this->data['protocolVersion'])) { + $this->parseProtocolVersion(); + } + return $this->data['protocolVersion']; + } + + /** + * Get host. + * + * @param bool $withoutPort + * @return string|null + */ + public function host(bool $withoutPort = false): ?string + { + $host = $this->header('host'); + if ($host && $withoutPort) { + return preg_replace('/:\d{1,5}$/', '', $host); + } + return $host; + } + + /** + * Get uri. + * + * @return string + */ + public function uri(): string + { + if (!isset($this->data['uri'])) { + $this->parseHeadFirstLine(); + } + return $this->data['uri']; + } + + /** + * Get path. + * + * @return string + */ + public function path(): string + { + if (!isset($this->data['path'])) { + $this->parseUriComponents(); + } + return $this->data['path']; + } + + /** + * Get query string. + * + * @return string + */ + public function queryString(): string + { + if (!isset($this->data['query_string'])) { + $this->parseUriComponents(); + } + return $this->data['query_string']; + } + + /** + * Parse URI into path and query string components (single parse_url call). + * + * @return void + */ + protected function parseUriComponents(): void + { + $uri = $this->uri(); + $parsed = parse_url($uri); + $this->data['path'] = $parsed['path'] ?? '/'; + $this->data['query_string'] = $parsed['query'] ?? ''; + } + + /** + * Get session. + * + * @return Session + * @throws Exception + */ + public function session(): Session + { + return $this->context['session'] ??= new Session($this->sessionId()); + } + + /** + * Get/Set session id. + * + * @param string|null $sessionId + * @return string + * @throws Exception + */ + public function sessionId(?string $sessionId = null): string + { + if ($sessionId) { + unset($this->context['sid'], $this->context['session']); + } + if (!isset($this->context['sid'])) { + $sessionName = Session::$name; + $sid = $sessionId ? '' : $this->cookie($sessionName); + // Strip surrounding double quotes (RFC 6265 allows DQUOTE-wrapped cookie values). + if (is_string($sid) && isset($sid[1]) && $sid[0] === '"' && $sid[-1] === '"') { + $sid = substr($sid, 1, -1); + } + $sid = $this->isValidSessionId($sid) ? $sid : ''; + if ($sid === '') { + if (!$this->connection) { + throw new RuntimeException('Request->session() fail, header already send'); + } + $sid = $sessionId ?: static::createSessionId(); + $cookieParams = Session::getCookieParams(); + $this->setSidCookie($sessionName, $sid, $cookieParams); + } + $this->context['sid'] = $sid; + } + return $this->context['sid']; + } + + /** + * Check if session id is valid. + * + * @param mixed $sessionId + * @return bool + */ + public function isValidSessionId(mixed $sessionId): bool + { + return is_string($sessionId) && preg_match('/^[a-zA-Z0-9,-]{16,256}$/', $sessionId); + } + + /** + * Session regenerate id. + * + * @param bool $deleteOldSession + * @return string + * @throws Exception + */ + public function sessionRegenerateId(bool $deleteOldSession = false): string + { + $session = $this->session(); + $sessionData = $session->all(); + if ($deleteOldSession) { + $session->flush(); + } + $newSid = static::createSessionId(); + $session = new Session($newSid); + $session->put($sessionData); + $cookieParams = Session::getCookieParams(); + $sessionName = Session::$name; + $this->setSidCookie($sessionName, $newSid, $cookieParams); + $this->context['sid'] = $newSid; + $this->context['session'] = $session; + return $newSid; + } + + /** + * Get http raw head. + * + * @return string + */ + public function rawHead(): string + { + return $this->data['head'] ??= strstr($this->buffer, "\r\n\r\n", true); + } + + /** + * Get http raw body. + * + * @return string + */ + public function rawBody(): string + { + return substr($this->buffer, strpos($this->buffer, "\r\n\r\n") + 4); + } + + /** + * Get raw buffer. + * + * @return string + */ + public function rawBuffer(): string + { + return $this->buffer; + } + + /** + * Parse first line of http header buffer. + * + * @return void + */ + protected function parseHeadFirstLine(): void + { + $firstLine = strstr($this->buffer, "\r\n", true); + $tmp = explode(' ', $firstLine, 3); + $this->data['method'] = $tmp[0]; + $this->data['uri'] = $tmp[1] ?? '/'; + } + + /** + * Parse protocol version. + * + * @return void + */ + protected function parseProtocolVersion(): void + { + $firstLine = strstr($this->buffer, "\r\n", true); + $httpStr = strstr($firstLine, 'HTTP/'); + $protocolVersion = $httpStr ? substr($httpStr, 5) : '1.0'; + $this->data['protocolVersion'] = $protocolVersion; + } + + /** + * Parse headers. + * + * @return void + */ + protected function parseHeaders(): void + { + static $cache = []; + $this->data['headers'] = []; + $rawHead = $this->rawHead(); + $endLinePosition = strpos($rawHead, "\r\n"); + if ($endLinePosition === false) { + return; + } + $headBuffer = substr($rawHead, $endLinePosition + 2); + $cacheable = !isset($headBuffer[static::MAX_CACHE_STRING_LENGTH]); + if ($cacheable && isset($cache[$headBuffer])) { + $this->data['headers'] = $cache[$headBuffer]; + return; + } + $headData = explode("\r\n", $headBuffer); + foreach ($headData as $content) { + if ($content === '') { + continue; + } + $parts = explode(':', $content, 2); + if (!isset($parts[1])) { + continue; + } + $key = strtolower($parts[0]); + $value = trim($parts[1], " \t"); + if (isset($this->data['headers'][$key])) { + $this->data['headers'][$key] = "{$this->data['headers'][$key]},$value"; + } else { + $this->data['headers'][$key] = $value; + } + } + if ($cacheable) { + $cache[$headBuffer] = $this->data['headers']; + if (count($cache) > static::MAX_CACHE_SIZE) { + unset($cache[key($cache)]); + } + } + } + + /** + * Parse head. + * + * @return void + */ + protected function parseGet(): void + { + static $cache = []; + $queryString = $this->queryString(); + $this->data['get'] = []; + if ($queryString === '') { + return; + } + $cacheable = !isset($queryString[static::MAX_CACHE_STRING_LENGTH]); + if ($cacheable && isset($cache[$queryString])) { + $this->data['get'] = $cache[$queryString]; + return; + } + parse_str($queryString, $this->data['get']); + if ($cacheable) { + $cache[$queryString] = $this->data['get']; + if (count($cache) > static::MAX_CACHE_SIZE) { + unset($cache[key($cache)]); + } + } + } + + /** + * Parse post. + * + * @return void + */ + protected function parsePost(): void + { + static $cache = []; + $this->data['post'] = $this->data['files'] = []; + $contentType = $this->header('content-type', ''); + if (preg_match('/boundary="?(\S+)"?/', $contentType, $match)) { + $httpPostBoundary = '--' . $match[1]; + $this->parseUploadFiles($httpPostBoundary); + return; + } + $bodyBuffer = $this->rawBody(); + if ($bodyBuffer === '') { + return; + } + $cacheable = !isset($bodyBuffer[static::MAX_CACHE_STRING_LENGTH]); + if ($cacheable && isset($cache[$bodyBuffer])) { + $this->data['post'] = $cache[$bodyBuffer]; + return; + } + if (str_contains($contentType, 'json')) { + $this->data['post'] = (array)json_decode($bodyBuffer, true); + } else { + parse_str($bodyBuffer, $this->data['post']); + } + if ($cacheable) { + $cache[$bodyBuffer] = $this->data['post']; + if (count($cache) > static::MAX_CACHE_SIZE) { + unset($cache[key($cache)]); + } + } + } + + /** + * Parse upload files. + * + * @param string $httpPostBoundary + * @return void + */ + protected function parseUploadFiles(string $httpPostBoundary): void + { + $httpPostBoundary = trim($httpPostBoundary, '"'); + $buffer = $this->buffer; + $postEncodeString = ''; + $filesEncodeString = ''; + $files = []; + $bodyPosition = strpos($buffer, "\r\n\r\n") + 4; + $offset = $bodyPosition + strlen($httpPostBoundary) + 2; + $maxCount = static::$maxFileUploads; + while ($maxCount-- > 0 && $offset) { + $offset = $this->parseUploadFile($httpPostBoundary, $offset, $postEncodeString, $filesEncodeString, $files); + } + if ($postEncodeString) { + parse_str($postEncodeString, $this->data['post']); + } + + if ($filesEncodeString) { + parse_str($filesEncodeString, $this->data['files']); + array_walk_recursive($this->data['files'], function (&$value) use ($files) { + $value = $files[$value]; + }); + } + } + + /** + * Parse upload file. + * + * @param string $boundary + * @param int $sectionStartOffset + * @param string $postEncodeString + * @param string $filesEncodeStr + * @param array $files + * @return int + */ + protected function parseUploadFile(string $boundary, int $sectionStartOffset, string &$postEncodeString, string &$filesEncodeStr, array &$files): int + { + $file = []; + $boundary = "\r\n$boundary"; + if (strlen($this->buffer) < $sectionStartOffset) { + return 0; + } + $sectionEndOffset = strpos($this->buffer, $boundary, $sectionStartOffset); + if (!$sectionEndOffset) { + return 0; + } + $contentLinesEndOffset = strpos($this->buffer, "\r\n\r\n", $sectionStartOffset); + if (!$contentLinesEndOffset || $contentLinesEndOffset + 4 > $sectionEndOffset) { + return 0; + } + $contentLinesStr = substr($this->buffer, $sectionStartOffset, $contentLinesEndOffset - $sectionStartOffset); + $contentLines = explode("\r\n", trim($contentLinesStr . "\r\n")); + $boundaryValue = substr($this->buffer, $contentLinesEndOffset + 4, $sectionEndOffset - $contentLinesEndOffset - 4); + $uploadKey = false; + foreach ($contentLines as $contentLine) { + if (!strpos($contentLine, ': ')) { + return 0; + } + [$key, $value] = explode(': ', $contentLine); + switch (strtolower($key)) { + + case "content-disposition": + // Is file data. + if (preg_match('/name="(.*?)"; filename="(.*?)"/i', $value, $match)) { + $error = 0; + $tmpFile = ''; + $fileName = $match[1]; + $size = strlen($boundaryValue); + $tmpUploadDir = HTTP::uploadTmpDir(); + if (!$tmpUploadDir) { + $error = UPLOAD_ERR_NO_TMP_DIR; + } else if ($boundaryValue === '' && $fileName === '') { + $error = UPLOAD_ERR_NO_FILE; + } else { + $tmpFile = tempnam($tmpUploadDir, 'workerman.upload.'); + if ($tmpFile === false || false === file_put_contents($tmpFile, $boundaryValue)) { + $error = UPLOAD_ERR_CANT_WRITE; + } + } + $uploadKey = $fileName; + // Parse upload files. + $file = [...$file, 'name' => $match[2], 'tmp_name' => $tmpFile, 'size' => $size, 'error' => $error, 'full_path' => $match[2]]; + $file['type'] ??= ''; + break; + } + // Is post field. + // Parse $POST. + if (preg_match('/name="(.*?)"$/', $value, $match)) { + $k = $match[1]; + $postEncodeString .= urlencode($k) . "=" . urlencode($boundaryValue) . '&'; + } + return $sectionEndOffset + strlen($boundary) + 2; + + case "content-type": + $file['type'] = trim($value); + break; + + case "webkitrelativepath": + $file['full_path'] = trim($value); + break; + } + } + if ($uploadKey === false) { + return 0; + } + $filesEncodeStr .= urlencode($uploadKey) . '=' . count($files) . '&'; + $files[] = $file; + + return $sectionEndOffset + strlen($boundary) + 2; + } + + /** + * Create session id. + * + * @return string + * @throws RuntimeException + */ + public static function createSessionId(): string + { + $sid = session_create_id(); + if ($sid === false) { + throw new RuntimeException('session_create_id() failed'); + } + return $sid; + } + + /** + * @param string $sessionName + * @param string $sid + * @param array $cookieParams + * @return void + */ + protected function setSidCookie(string $sessionName, string $sid, array $cookieParams): void + { + if (!$this->connection) { + throw new RuntimeException('Request->setSidCookie() fail, header already send'); + } + $this->connection->headers['Set-Cookie'] = [$sessionName . '=' . $sid + . (empty($cookieParams['domain']) ? '' : '; Domain=' . $cookieParams['domain']) + . (empty($cookieParams['lifetime']) ? '' : '; Max-Age=' . $cookieParams['lifetime']) + . (empty($cookieParams['path']) ? '' : '; Path=' . $cookieParams['path']) + . (empty($cookieParams['samesite']) ? '' : '; SameSite=' . $cookieParams['samesite']) + . (!$cookieParams['secure'] ? '' : '; Secure') + . (!$cookieParams['httponly'] ? '' : '; HttpOnly')]; + } + + /** + * __toString. + */ + public function __toString(): string + { + return $this->buffer; + } + + /** + * Setter. + * + * @param string $name + * @param mixed $value + * @return void + */ + public function __set(string $name, mixed $value): void + { + $this->properties[$name] = $value; + } + + /** + * Getter. + * + * @param string $name + * @return mixed + */ + public function __get(string $name): mixed + { + return $this->properties[$name] ?? null; + } + + /** + * Isset. + * + * @param string $name + * @return bool + */ + public function __isset(string $name): bool + { + return isset($this->properties[$name]); + } + + /** + * Unset. + * + * @param string $name + * @return void + */ + public function __unset(string $name): void + { + unset($this->properties[$name]); + } + + /** + * __unserialize. + * + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->isSafe = false; + } + + /** + * Destroy. + * + * @return void + */ + public function destroy(): void + { + if ($this->context) { + $this->context = []; + } + if ($this->properties) { + $this->properties = []; + } + $this->connection = null; + } + + /** + * Destructor. + * + * @return void + */ + public function __destruct() + { + if (!empty($this->data['files']) && $this->isSafe) { + clearstatcache(); + array_walk_recursive($this->data['files'], function ($value, $key) { + if ($key === 'tmp_name' && is_file($value)) { + unlink($value); + } + }); + } + } + +} diff --git a/vendor/workerman/workerman/src/Protocols/Http/Response.php b/vendor/workerman/workerman/src/Protocols/Http/Response.php new file mode 100644 index 0000000..76c901b --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/Response.php @@ -0,0 +1,591 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http; + +use Stringable; + +use function array_merge_recursive; +use function filemtime; +use function gmdate; +use function is_array; +use function is_file; +use function pathinfo; +use function rawurlencode; +use function strlen; + +/** + * Class Response + * @package Workerman\Protocols\Http + */ +class Response implements Stringable +{ + + /** + * Http reason. + * + * @var ?string + */ + protected ?string $reason = null; + + /** + * Http version. + * + * @var string + */ + protected string $version = '1.1'; + + /** + * Send file info + * + * @var ?array + */ + public ?array $file = null; + + /** + * Mime Type map. + * @var array + */ + protected static array $mimeTypeMap = [ + // text + 'html' => 'text/html', + 'htm' => 'text/html', + 'shtml' => 'text/html', + 'css' => 'text/css', + 'xml' => 'text/xml', + 'mml' => 'text/mathml', + 'txt' => 'text/plain', + 'jad' => 'text/vnd.sun.j2me.app-descriptor', + 'wml' => 'text/vnd.wap.wml', + 'htc' => 'text/x-component', + + // image + 'gif' => 'image/gif', + 'jpeg' => 'image/jpeg', + 'jpg' => 'image/jpeg', + 'png' => 'image/png', + 'tif' => 'image/tiff', + 'tiff' => 'image/tiff', + 'wbmp' => 'image/vnd.wap.wbmp', + 'ico' => 'image/x-icon', + 'jng' => 'image/x-jng', + 'bmp' => 'image/x-ms-bmp', + 'svg' => 'image/svg+xml', + 'svgz' => 'image/svg+xml', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + + // application + 'js' => 'application/javascript', + 'atom' => 'application/atom+xml', + 'rss' => 'application/rss+xml', + 'wasm' => 'application/wasm', + 'jar' => 'application/java-archive', + 'war' => 'application/java-archive', + 'ear' => 'application/java-archive', + 'json' => 'application/json', + 'hqx' => 'application/mac-binhex40', + 'doc' => 'application/msword', + 'pdf' => 'application/pdf', + 'ps' => 'application/postscript', + 'eps' => 'application/postscript', + 'ai' => 'application/postscript', + 'rtf' => 'application/rtf', + 'm3u8' => 'application/vnd.apple.mpegurl', + 'xls' => 'application/vnd.ms-excel', + 'eot' => 'application/vnd.ms-fontobject', + 'ppt' => 'application/vnd.ms-powerpoint', + 'wmlc' => 'application/vnd.wap.wmlc', + 'kml' => 'application/vnd.google-earth.kml+xml', + 'kmz' => 'application/vnd.google-earth.kmz', + '7z' => 'application/x-7z-compressed', + 'cco' => 'application/x-cocoa', + 'jardiff' => 'application/x-java-archive-diff', + 'jnlp' => 'application/x-java-jnlp-file', + 'run' => 'application/x-makeself', + 'pl' => 'application/x-perl', + 'pm' => 'application/x-perl', + 'prc' => 'application/x-pilot', + 'pdb' => 'application/x-pilot', + 'rar' => 'application/x-rar-compressed', + 'rpm' => 'application/x-redhat-package-manager', + 'sea' => 'application/x-sea', + 'swf' => 'application/x-shockwave-flash', + 'sit' => 'application/x-stuffit', + 'tcl' => 'application/x-tcl', + 'tk' => 'application/x-tcl', + 'der' => 'application/x-x509-ca-cert', + 'pem' => 'application/x-x509-ca-cert', + 'crt' => 'application/x-x509-ca-cert', + 'xpi' => 'application/x-xpinstall', + 'xhtml' => 'application/xhtml+xml', + 'xspf' => 'application/xspf+xml', + 'zip' => 'application/zip', + 'bin' => 'application/octet-stream', + 'exe' => 'application/octet-stream', + 'dll' => 'application/octet-stream', + 'deb' => 'application/octet-stream', + 'dmg' => 'application/octet-stream', + 'iso' => 'application/octet-stream', + 'img' => 'application/octet-stream', + 'msi' => 'application/octet-stream', + 'msp' => 'application/octet-stream', + 'msm' => 'application/octet-stream', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + + // audio + 'mid' => 'audio/midi', + 'midi' => 'audio/midi', + 'kar' => 'audio/midi', + 'mp3' => 'audio/mpeg', + 'ogg' => 'audio/ogg', + 'm4a' => 'audio/x-m4a', + 'ra' => 'audio/x-realaudio', + + // video + '3gpp' => 'video/3gpp', + '3gp' => 'video/3gpp', + 'ts' => 'video/mp2t', + 'mp4' => 'video/mp4', + 'mpeg' => 'video/mpeg', + 'mpg' => 'video/mpeg', + 'mov' => 'video/quicktime', + 'webm' => 'video/webm', + 'flv' => 'video/x-flv', + 'm4v' => 'video/x-m4v', + 'mng' => 'video/x-mng', + 'asx' => 'video/x-ms-asf', + 'asf' => 'video/x-ms-asf', + 'wmv' => 'video/x-ms-wmv', + 'avi' => 'video/x-msvideo', + + // font + 'ttf' => 'font/ttf', + 'woff' => 'font/woff', + 'woff2' => 'font/woff2', + ]; + + /** + * Phrases. + * + * @var array + * + * @link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes + */ + public const PHRASES = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', // WebDAV; RFC 2518 + 103 => 'Early Hints', // RFC 8297 + + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', // since HTTP/1.1 + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', // RFC 7233 + 207 => 'Multi-Status', // WebDAV; RFC 4918 + 208 => 'Already Reported', // WebDAV; RFC 5842 + 226 => 'IM Used', // RFC 3229 + + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', // Previously "Moved temporarily" + 303 => 'See Other', // since HTTP/1.1 + 304 => 'Not Modified', // RFC 7232 + 305 => 'Use Proxy', // since HTTP/1.1 + 306 => 'Switch Proxy', + 307 => 'Temporary Redirect', // since HTTP/1.1 + 308 => 'Permanent Redirect', // RFC 7538 + + 400 => 'Bad Request', + 401 => 'Unauthorized', // RFC 7235 + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', // RFC 7235 + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', // RFC 7232 + 413 => 'Payload Too Large', // RFC 7231 + 414 => 'URI Too Long', // RFC 7231 + 415 => 'Unsupported Media Type', // RFC 7231 + 416 => 'Range Not Satisfiable', // RFC 7233 + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', // RFC 2324, RFC 7168 + 421 => 'Misdirected Request', // RFC 7540 + 422 => 'Unprocessable Entity', // WebDAV; RFC 4918 + 423 => 'Locked', // WebDAV; RFC 4918 + 424 => 'Failed Dependency', // WebDAV; RFC 4918 + 425 => 'Too Early', // RFC 8470 + 426 => 'Upgrade Required', + 428 => 'Precondition Required', // RFC 6585 + 429 => 'Too Many Requests', // RFC 6585 + 431 => 'Request Header Fields Too Large', // RFC 6585 + 451 => 'Unavailable For Legal Reasons', // RFC 7725 + + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', // RFC 2295 + 507 => 'Insufficient Storage', // WebDAV; RFC 4918 + 508 => 'Loop Detected', // WebDAV; RFC 5842 + 510 => 'Not Extended', // RFC 2774 + 511 => 'Network Authentication Required', // RFC 6585 + ]; + + /** + * Response constructor. + * + * @param int $status + * @param array $headers + * @param string $body + */ + public function __construct( + protected int $status = 200, + protected array $headers = [], + protected string $body = '' + ) {} + + /** + * Set header. + * + * @param string $name + * @param string $value + * @return $this + */ + public function header(string $name, string $value): static + { + $this->headers[$name] = $value; + return $this; + } + + /** + * Set header. + * + * @param string $name + * @param string $value + * @return $this + */ + public function withHeader(string $name, string $value): static + { + return $this->header($name, $value); + } + + /** + * Set headers. + * + * @param array $headers + * @return $this + */ + public function withHeaders(array $headers): static + { + $this->headers = array_merge_recursive($this->headers, $headers); + return $this; + } + + /** + * Remove header. + * + * @param string $name + * @return $this + */ + public function withoutHeader(string $name): static + { + unset($this->headers[$name]); + return $this; + } + + /** + * Get header. + * + * @param string $name + * @return null|array|string + */ + public function getHeader(string $name): array|string|null + { + return $this->headers[$name] ?? null; + } + + /** + * Get headers. + * + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Get Mime Type from extension. + * + * @param string $ext + * @return string + */ + public function getMimeType(string $ext): string + { + return self::$mimeTypeMap[$ext] ?? 'application/octet-stream'; + } + + /** + * Set status. + * + * @param int $code + * @param string|null $reasonPhrase + * @return $this + */ + public function withStatus(int $code, ?string $reasonPhrase = null): static + { + $this->status = $code; + $this->reason = $reasonPhrase !== null ? str_replace(["\r", "\n"], '', $reasonPhrase) : null; + return $this; + } + + /** + * Get status code. + * + * @return int + */ + public function getStatusCode(): int + { + return $this->status; + } + + /** + * Get reason phrase. + * + * @return ?string + */ + public function getReasonPhrase(): ?string + { + return $this->reason; + } + + /** + * Set protocol version. + * + * @param string $version + * @return $this + */ + public function withProtocolVersion(string $version): static + { + $this->version = str_replace(["\r", "\n"], '', $version); + return $this; + } + + /** + * Set http body. + * + * @param string $body + * @return $this + */ + public function withBody(string $body): static + { + $this->body = $body; + return $this; + } + + /** + * Get http raw body. + * + * @return string + */ + public function rawBody(): string + { + return $this->body; + } + + /** + * Send file. + * + * @param string $file + * @param int $offset + * @param int $length + * @return $this + */ + public function withFile(string $file, int $offset = 0, int $length = 0): static + { + if (!is_file($file)) { + return $this->withStatus(404)->withBody('

404 Not Found

'); + } + $this->file = ['file' => $file, 'offset' => $offset, 'length' => $length]; + return $this; + } + + /** + * Set cookie. + * + * @param string $name + * @param string $value + * @param int|null $maxAge + * @param string $path + * @param string $domain + * @param bool $secure + * @param bool $httpOnly + * @param string $sameSite + * @return $this + */ + public function cookie(string $name, string $value = '', ?int $maxAge = null, string $path = '', string $domain = '', bool $secure = false, bool $httpOnly = false, string $sameSite = ''): static + { + $this->headers['Set-Cookie'][] = $name . '=' . rawurlencode($value) + . (empty($domain) ? '' : '; Domain=' . $domain) + . ($maxAge === null ? '' : '; Max-Age=' . $maxAge) + . (empty($path) ? '' : '; Path=' . $path) + . (!$secure ? '' : '; Secure') + . (!$httpOnly ? '' : '; HttpOnly') + . (empty($sameSite) ? '' : '; SameSite=' . $sameSite); + return $this; + } + + /** + * Create header for file. + * + * @param array $fileInfo + * @return string + */ + protected function createHeadForFile(array $fileInfo): string + { + $file = $fileInfo['file']; + $reason = $this->reason ?: self::PHRASES[$this->status]; + $head = "HTTP/$this->version $this->status $reason\r\n"; + $headers = $this->headers; + foreach ($headers as $name => $value) { + // Skip unsafe header names + if (strpbrk((string)$name, ":\r\n") !== false) { + continue; + } + if (is_array($value)) { + foreach ($value as $item) { + // Skip unsafe header values + if (strpbrk((string)$item, "\r\n") !== false) { + continue; + } + $head .= "$name: $item\r\n"; + } + continue; + } + // Skip unsafe header values + if (strpbrk((string)$value, "\r\n") !== false) { + continue; + } + $head .= "$name: $value\r\n"; + } + + if (!isset($headers['Connection'])) { + $head .= "Connection: keep-alive\r\n"; + } + + $fileInfo = pathinfo($file); + $extension = $fileInfo['extension'] ?? ''; + $baseName = $fileInfo['basename'] ?: 'unknown'; + // Remove ASCII control characters (0x00-0x1F, 0x7F) and unsafe quotes/backslashes to avoid breaking header formatting + $baseName = preg_replace('/["\\\\\x00-\x1F\x7F]/', '', $baseName); + if ($baseName === '') { + $baseName = 'unknown'; + } + $mime = ''; + if (!isset($headers['Content-Type'])) { + $mime = $this->getMimeType($extension); + $head .= "Content-Type: " . $mime . "\r\n"; + } + + if (!isset($headers['Content-Disposition']) && $mime === 'application/octet-stream') { + $head .= "Content-Disposition: attachment; filename=\"$baseName\"\r\n"; + } + + if (!isset($headers['Last-Modified']) && $mtime = filemtime($file)) { + $head .= 'Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT' . "\r\n"; + } + + return "$head\r\n"; + } + + /** + * __toString. + * + * @return string + */ + public function __toString(): string + { + if ($this->file) { + return $this->createHeadForFile($this->file); + } + + $reason = $this->reason ?: self::PHRASES[$this->status] ?? ''; + $bodyLen = strlen($this->body); + if (empty($this->headers)) { + return "HTTP/$this->version $this->status $reason\r\nContent-Type: text/html;charset=utf-8\r\nContent-Length: $bodyLen\r\nConnection: keep-alive\r\n\r\n$this->body"; + } + + $head = "HTTP/$this->version $this->status $reason\r\n"; + $headers = $this->headers; + foreach ($headers as $name => $value) { + // Skip unsafe header names + if (strpbrk((string)$name, ":\r\n") !== false) { + continue; + } + if (is_array($value)) { + foreach ($value as $item) { + // Skip unsafe header values + if (strpbrk((string)$item, "\r\n") !== false) { + continue; + } + $head .= "$name: $item\r\n"; + } + continue; + } + // Skip unsafe header values + if (strpbrk((string)$value, "\r\n") !== false) { + continue; + } + $head .= "$name: $value\r\n"; + } + + if (!isset($headers['Connection'])) { + $head .= "Connection: keep-alive\r\n"; + } + + if (!isset($headers['Content-Type'])) { + $head .= "Content-Type: text/html;charset=utf-8\r\n"; + } else if ($headers['Content-Type'] === 'text/event-stream') { + // For Server-Sent Events, send headers once and keep the connection open. + // Headers must be terminated by an empty line; ignore any preset body to avoid + // polluting the event stream with extra bytes or OS-specific newlines. + return $head . "\r\n"; + } + + if (!isset($headers['Transfer-Encoding'])) { + $head .= "Content-Length: $bodyLen\r\n\r\n"; + } else { + return $bodyLen ? "$head\r\n" . dechex($bodyLen) . "\r\n$this->body\r\n" : "$head\r\n"; + } + + // The whole http package + return $head . $this->body; + } + +} diff --git a/vendor/workerman/workerman/src/Protocols/Http/ServerSentEvents.php b/vendor/workerman/workerman/src/Protocols/Http/ServerSentEvents.php new file mode 100644 index 0000000..6b70b10 --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/ServerSentEvents.php @@ -0,0 +1,56 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http; + +use Stringable; + +use function str_replace; + +/** + * Class ServerSentEvents + * @package Workerman\Protocols\Http + */ +class ServerSentEvents implements Stringable +{ + /** + * ServerSentEvents constructor. + * $data for example ['event'=>'ping', 'data' => 'some thing', 'id' => 1000, 'retry' => 5000] + */ + public function __construct(protected array $data) {} + + public function __toString(): string + { + $buffer = ''; + $data = $this->data; + if (isset($data[''])) { + $buffer = ": {$data['']}\n"; + } + if (isset($data['event'])) { + $buffer .= "event: {$data['event']}\n"; + } + if (isset($data['id'])) { + $buffer .= "id: {$data['id']}\n"; + } + if (isset($data['retry'])) { + $buffer .= "retry: {$data['retry']}\n"; + } + if (isset($data['data'])) { + $buffer .= 'data: ' . str_replace("\n", "\ndata: ", $data['data']) . "\n"; + } + return "$buffer\n"; + } +} diff --git a/vendor/workerman/workerman/src/Protocols/Http/Session.php b/vendor/workerman/workerman/src/Protocols/Http/Session.php new file mode 100644 index 0000000..add7baa --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/Session.php @@ -0,0 +1,477 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http; + +use Exception; +use RuntimeException; +use Throwable; +use Workerman\Protocols\Http\Session\FileSessionHandler; +use Workerman\Protocols\Http\Session\SessionHandlerInterface; +use function array_key_exists; +use function ini_get; +use function is_array; +use function is_scalar; +use function random_int; +use function session_get_cookie_params; + +/** + * Class Session + * @package Workerman\Protocols\Http + */ +class Session +{ + /** + * Session andler class which implements SessionHandlerInterface. + * + * @var string + */ + protected static string $handlerClass = FileSessionHandler::class; + + /** + * Parameters of __constructor for session handler class. + * + * @var mixed + */ + protected static mixed $handlerConfig = null; + + /** + * Session name. + * + * @var string + */ + public static string $name = 'PHPSID'; + + /** + * Auto update timestamp. + * + * @var bool + */ + public static bool $autoUpdateTimestamp = false; + + /** + * Session lifetime. + * + * @var int + */ + public static int $lifetime = 1440; + + /** + * Cookie lifetime. + * + * @var int + */ + public static int $cookieLifetime = 1440; + + /** + * Session cookie path. + * + * @var string + */ + public static string $cookiePath = '/'; + + /** + * Session cookie domain. + * + * @var string + */ + public static string $domain = ''; + + /** + * HTTPS only cookies. + * + * @var bool + */ + public static bool $secure = false; + + /** + * HTTP access only. + * + * @var bool + */ + public static bool $httpOnly = true; + + /** + * Same-site cookies. + * + * @var string + */ + public static string $sameSite = ''; + + /** + * Gc probability. + * + * @var int[] + */ + public static array $gcProbability = [1, 20000]; + + /** + * Session handler instance. + * + * @var ?SessionHandlerInterface + */ + protected static ?SessionHandlerInterface $handler = null; + + /** + * Session data. + * + * @var array + */ + protected mixed $data = []; + + /** + * Session changed and need to save. + * + * @var bool + */ + protected bool $needSave = false; + + /** + * Session id. + * + * @var string + */ + protected string $sessionId; + + /** + * Is safe. + * + * @var bool + */ + protected bool $isSafe = true; + + /** + * Session serialize_handler + * @var array|string[] + */ + protected array $serializer = ['serialize', 'unserialize']; + + /** + * Session constructor. + * + * @param string $sessionId + */ + public function __construct(string $sessionId) + { + if (extension_loaded('igbinary') && ini_get('session.serialize_handler') == 'igbinary') { + $this->serializer = ['igbinary_serialize', 'igbinary_unserialize']; + } + if (static::$handler === null) { + static::initHandler(); + } + $this->sessionId = $sessionId; + if ($data = static::$handler->read($sessionId)) { + $this->data = $this->safeDeserialize($data); + } + } + + /** + * Get session id. + * + * @return string + */ + public function getId(): string + { + return $this->sessionId; + } + + /** + * Get session. + * + * @param string $name + * @param mixed $default + * @return mixed + */ + public function get(string $name, mixed $default = null): mixed + { + return $this->data[$name] ?? $default; + } + + /** + * Store data in the session. + * + * @param string $name + * @param mixed $value + */ + public function set(string $name, mixed $value): void + { + $this->data[$name] = $value; + $this->needSave = true; + } + + /** + * Delete an item from the session. + * + * @param string $name + */ + public function delete(string $name): void + { + unset($this->data[$name]); + $this->needSave = true; + } + + /** + * Retrieve and delete an item from the session. + * + * @param string $name + * @param mixed $default + * @return mixed + */ + public function pull(string $name, mixed $default = null): mixed + { + $value = $this->get($name, $default); + $this->delete($name); + return $value; + } + + /** + * Store data in the session. + * + * @param array|string $key + * @param mixed $value + */ + public function put(array|string $key, mixed $value = null): void + { + if (!is_array($key)) { + $this->set($key, $value); + return; + } + + foreach ($key as $k => $v) { + $this->data[$k] = $v; + } + $this->needSave = true; + } + + /** + * Remove a piece of data from the session. + * + * @param array|string $name + */ + public function forget(array|string $name): void + { + if (is_scalar($name)) { + $this->delete($name); + return; + } + foreach ($name as $key) { + unset($this->data[$key]); + } + $this->needSave = true; + } + + /** + * Retrieve all the data in the session. + * + * @return array + */ + public function all(): array + { + return $this->data; + } + + /** + * Remove all data from the session. + * + * @return void + */ + public function flush(): void + { + $this->needSave = true; + $this->data = []; + } + + /** + * Determining If An Item Exists In The Session. + * + * @param string $name + * @return bool + */ + public function has(string $name): bool + { + return isset($this->data[$name]); + } + + /** + * To determine if an item is present in the session, even if its value is null. + * + * @param string $name + * @return bool + */ + public function exists(string $name): bool + { + return array_key_exists($name, $this->data); + } + + /** + * Save session to store. + * + * @return void + */ + public function save(): void + { + if ($this->needSave) { + if (empty($this->data)) { + static::$handler->destroy($this->sessionId); + } else { + static::$handler->write($this->sessionId, $this->serializer[0]($this->data)); + } + } elseif (static::$autoUpdateTimestamp) { + $this->refresh(); + } + $this->needSave = false; + } + + /** + * Refresh session expire time. + * + * @return bool + */ + public function refresh(): bool + { + return static::$handler->updateTimestamp($this->getId()); + } + + /** + * Init. + * + * @return void + */ + public static function init(): void + { + if (($gcProbability = (int)ini_get('session.gc_probability')) && ($gcDivisor = (int)ini_get('session.gc_divisor'))) { + static::$gcProbability = [$gcProbability, $gcDivisor]; + } + + if ($gcMaxLifeTime = ini_get('session.gc_maxlifetime')) { + self::$lifetime = (int)$gcMaxLifeTime; + } + + $sessionCookieParams = session_get_cookie_params(); + static::$cookieLifetime = $sessionCookieParams['lifetime']; + static::$cookiePath = $sessionCookieParams['path']; + static::$domain = $sessionCookieParams['domain']; + static::$secure = $sessionCookieParams['secure']; + static::$httpOnly = $sessionCookieParams['httponly']; + } + + /** + * Set session handler class. + * + * @param mixed $className + * @param mixed $config + * @return string + */ + public static function handlerClass(mixed $className = null, mixed $config = null): string + { + if ($className) { + static::$handlerClass = $className; + } + if ($config) { + static::$handlerConfig = $config; + } + return static::$handlerClass; + } + + /** + * Get cookie params. + * + * @return array + */ + public static function getCookieParams(): array + { + return [ + 'lifetime' => static::$cookieLifetime, + 'path' => static::$cookiePath, + 'domain' => static::$domain, + 'secure' => static::$secure, + 'httponly' => static::$httpOnly, + 'samesite' => static::$sameSite, + ]; + } + + /** + * Init handler. + * + * @return void + */ + protected static function initHandler(): void + { + if (static::$handlerConfig === null) { + static::$handler = new static::$handlerClass(); + } else { + static::$handler = new static::$handlerClass(static::$handlerConfig); + } + } + + /** + * Safely deserialize session data, preventing object instantiation. + * + * @param string $data + * @return array + */ + protected function safeDeserialize(string $data): array + { + if ($this->serializer[1] === 'unserialize') { + $result = unserialize($data, ['allowed_classes' => false]); + } else { + $result = ($this->serializer[1])($data); + } + return is_array($result) ? $result : []; + } + + /** + * GC sessions. + * + * @return void + */ + public function gc(): void + { + static::$handler->gc(static::$lifetime); + } + + /** + * __unserialize. + * + * @param array $data + * @return void + */ + public function __unserialize(array $data): void + { + $this->isSafe = false; + } + + /** + * __destruct. + * + * @return void + * @throws Throwable + */ + public function __destruct() + { + if (!$this->isSafe) { + return; + } + $this->save(); + if (random_int(1, static::$gcProbability[1]) <= static::$gcProbability[0]) { + $this->gc(); + } + } + +} + +// Init session. +Session::init(); diff --git a/vendor/workerman/workerman/src/Protocols/Http/Session/FileSessionHandler.php b/vendor/workerman/workerman/src/Protocols/Http/Session/FileSessionHandler.php new file mode 100644 index 0000000..219171c --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/Session/FileSessionHandler.php @@ -0,0 +1,209 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http\Session; + +use Exception; +use Workerman\Protocols\Http\Session; +use function clearstatcache; +use function file_get_contents; +use function file_put_contents; +use function filemtime; +use function glob; +use function is_dir; +use function is_file; +use function mkdir; +use function rename; +use function session_save_path; +use function strlen; +use function sys_get_temp_dir; +use function time; +use function touch; +use function unlink; + +/** + * Class FileSessionHandler + * @package Workerman\Protocols\Http\Session + */ +class FileSessionHandler implements SessionHandlerInterface +{ + /** + * Session save path. + * + * @var string + */ + protected static string $sessionSavePath; + + /** + * Session file prefix. + * + * @var string + */ + protected static string $sessionFilePrefix = 'session_'; + + /** + * Init. + */ + public static function init() + { + $savePath = @session_save_path(); + if (!$savePath || str_starts_with($savePath, 'tcp://')) { + $savePath = sys_get_temp_dir(); + } + static::sessionSavePath($savePath); + } + + /** + * FileSessionHandler constructor. + * @param array $config + */ + public function __construct(array $config = []) + { + if (isset($config['save_path'])) { + static::sessionSavePath($config['save_path']); + } + } + + /** + * {@inheritdoc} + */ + public function open(string $savePath, string $name): bool + { + return true; + } + + /** + * {@inheritdoc} + */ + public function read(string $sessionId): string|false + { + $sessionFile = static::sessionFile($sessionId); + clearstatcache(); + if (is_file($sessionFile)) { + if (time() - filemtime($sessionFile) > Session::$lifetime) { + unlink($sessionFile); + return false; + } + $data = file_get_contents($sessionFile); + return $data ?: false; + } + return false; + } + + /** + * {@inheritdoc} + * @throws Exception + */ + public function write(string $sessionId, string $sessionData): bool + { + $tempFile = static::$sessionSavePath . uniqid(bin2hex(random_bytes(8)), true); + if (!file_put_contents($tempFile, $sessionData)) { + return false; + } + return rename($tempFile, static::sessionFile($sessionId)); + } + + /** + * Update session modify time. + * + * @see https://www.php.net/manual/en/class.sessionupdatetimestamphandlerinterface.php + * @see https://www.php.net/manual/zh/function.touch.php + * + * @param string $sessionId Session id. + * @param string $data Session Data. + * + * @return bool + */ + public function updateTimestamp(string $sessionId, string $data = ""): bool + { + $sessionFile = static::sessionFile($sessionId); + if (!file_exists($sessionFile)) { + return false; + } + // set file modify time to current time + $setModifyTime = touch($sessionFile); + // clear file stat cache + clearstatcache(); + return $setModifyTime; + } + + /** + * {@inheritdoc} + */ + public function close(): bool + { + return true; + } + + /** + * {@inheritdoc} + */ + public function destroy(string $sessionId): bool + { + $sessionFile = static::sessionFile($sessionId); + if (is_file($sessionFile)) { + unlink($sessionFile); + } + return true; + } + + /** + * {@inheritdoc} + */ + public function gc(int $maxLifetime): bool + { + $timeNow = time(); + foreach (glob(static::$sessionSavePath . static::$sessionFilePrefix . '*') as $file) { + if (is_file($file) && $timeNow - filemtime($file) > $maxLifetime) { + unlink($file); + } + } + return true; + } + + /** + * Get session file path. + * + * @param string $sessionId + * @return string + */ + protected static function sessionFile(string $sessionId): string + { + return static::$sessionSavePath . static::$sessionFilePrefix . $sessionId; + } + + /** + * Get or set session file path. + * + * @param string $path + * @return string + */ + public static function sessionSavePath(string $path): string + { + if ($path) { + if ($path[strlen($path) - 1] !== DIRECTORY_SEPARATOR) { + $path .= DIRECTORY_SEPARATOR; + } + static::$sessionSavePath = $path; + if (!is_dir($path)) { + mkdir($path, 0777, true); + } + } + return $path; + } +} + +FileSessionHandler::init(); \ No newline at end of file diff --git a/vendor/workerman/workerman/src/Protocols/Http/Session/RedisClusterSessionHandler.php b/vendor/workerman/workerman/src/Protocols/Http/Session/RedisClusterSessionHandler.php new file mode 100644 index 0000000..8580d0d --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/Session/RedisClusterSessionHandler.php @@ -0,0 +1,59 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http\Session; + +use Redis; +use RedisCluster; +use RedisClusterException; +use RedisException; + +class RedisClusterSessionHandler extends RedisSessionHandler +{ + /** + * @param array $config + * @throws RedisClusterException + * @throws RedisException + */ + public function __construct(array $config) + { + parent::__construct($config); + } + + /** + * Create redis connection. + * @param array $config + * @return Redis|RedisCluster + * @throws RedisClusterException + */ + protected function createRedisConnection(array $config): Redis|RedisCluster + { + $timeout = $config['timeout'] ?? 2; + $readTimeout = $config['read_timeout'] ?? $timeout; + $persistent = $config['persistent'] ?? false; + $auth = $config['auth'] ?? ''; + $args = [null, $config['host'], $timeout, $readTimeout, $persistent]; + if ($auth) { + $args[] = $auth; + } + $redis = new RedisCluster(...$args); + if (empty($config['prefix'])) { + $config['prefix'] = 'redis_session_'; + } + $redis->setOption(Redis::OPT_PREFIX, $config['prefix']); + return $redis; + } +} diff --git a/vendor/workerman/workerman/src/Protocols/Http/Session/RedisSessionHandler.php b/vendor/workerman/workerman/src/Protocols/Http/Session/RedisSessionHandler.php new file mode 100644 index 0000000..e62af13 --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/Session/RedisSessionHandler.php @@ -0,0 +1,220 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http\Session; + +use Redis; +use RedisCluster; +use RedisException; +use RuntimeException; +use Throwable; +use Workerman\Coroutine\Utils\DestructionWatcher; +use Workerman\Events\Fiber; +use Workerman\Protocols\Http\Session; +use Workerman\Timer; +use Workerman\Coroutine\Pool; +use Workerman\Coroutine\Context; +use Workerman\Worker; + +/** + * Class RedisSessionHandler + * @package Workerman\Protocols\Http\Session + */ +class RedisSessionHandler implements SessionHandlerInterface +{ + /** + * @var Redis|RedisCluster + */ + protected Redis|RedisCluster|null $connection = null; + + /** + * @var array + */ + protected array $config; + + /** + * @var Pool|null + */ + protected static ?Pool $pool = null; + + /** + * RedisSessionHandler constructor. + * @param array $config = [ + * 'host' => '127.0.0.1', + * 'port' => 6379, + * 'timeout' => 2, + * 'auth' => '******', + * 'database' => 2, + * 'prefix' => 'redis_session_', + * 'ping' => 55, + * ] + * @throws RedisException + */ + public function __construct(array $config) + { + if (false === extension_loaded('redis')) { + throw new RuntimeException('Please install redis extension.'); + } + + $config['timeout'] ??= 2; + $this->config = $config; + } + + /** + * Get connection. + * @return Redis + * @throws Throwable + */ + protected function connection(): Redis|RedisCluster + { + // Cannot switch fibers in current execution context when PHP < 8.4 + if (Worker::$eventLoopClass === Fiber::class && PHP_VERSION_ID < 80400) { + if (!$this->connection) { + $this->connection = $this->createRedisConnection($this->config); + Timer::delay($this->config['pool']['heartbeat_interval'] ?? 55, function () { + $this->connection->ping(); + }); + } + return $this->connection; + } + + $key = 'session.redis.connection'; + /** @var Redis|null $connection */ + $connection = Context::get($key); + if (!$connection) { + if (!static::$pool) { + $poolConfig = $this->config['pool'] ?? []; + static::$pool = new Pool($poolConfig['max_connections'] ?? 10, $poolConfig); + static::$pool->setConnectionCreator(function () { + return $this->createRedisConnection($this->config); + }); + static::$pool->setConnectionCloser(function (Redis|RedisCluster $connection) { + $connection->close(); + }); + static::$pool->setHeartbeatChecker(function (Redis|RedisCluster $connection) { + $connection->ping(); + }); + } + try { + $connection = static::$pool->get(); + Context::set($key, $connection); + } finally { + $closure = function () use ($connection) { + try { + $connection && static::$pool && static::$pool->put($connection); + } catch (Throwable) { + // ignore + } + }; + $obj = Context::get('context.onDestroy'); + if (!$obj) { + $obj = new \stdClass(); + Context::set('context.onDestroy', $obj); + } + DestructionWatcher::watch($obj, $closure); + } + } + return $connection; + } + + /** + * Create redis connection. + * @param array $config + * @return Redis + */ + protected function createRedisConnection(array $config): Redis|RedisCluster + { + $redis = new Redis(); + if (false === $redis->connect($config['host'], $config['port'], $config['timeout'])) { + throw new RuntimeException("Redis connect {$config['host']}:{$config['port']} fail."); + } + if (!empty($config['auth'])) { + $redis->auth($config['auth']); + } + if (!empty($config['database'])) { + $redis->select((int)$config['database']); + } + if (empty($config['prefix'])) { + $config['prefix'] = 'redis_session_'; + } + $redis->setOption(Redis::OPT_PREFIX, $config['prefix']); + return $redis; + } + + /** + * {@inheritdoc} + */ + public function open(string $savePath, string $name): bool + { + return true; + } + + /** + * {@inheritdoc} + * @param string $sessionId + * @return string|false + * @throws RedisException + * @throws Throwable + */ + public function read(string $sessionId): string|false + { + return $this->connection()->get($sessionId); + } + + /** + * {@inheritdoc} + * @throws RedisException + */ + public function write(string $sessionId, string $sessionData): bool + { + return true === $this->connection()->setex($sessionId, Session::$lifetime, $sessionData); + } + + /** + * {@inheritdoc} + * @throws RedisException + */ + public function updateTimestamp(string $sessionId, string $data = ""): bool + { + return true === $this->connection()->expire($sessionId, Session::$lifetime); + } + + /** + * {@inheritdoc} + * @throws RedisException + */ + public function destroy(string $sessionId): bool + { + $this->connection()->del($sessionId); + return true; + } + + /** + * {@inheritdoc} + */ + public function close(): bool + { + return true; + } + + /** + * {@inheritdoc} + */ + public function gc(int $maxLifetime): bool + { + return true; + } +} diff --git a/vendor/workerman/workerman/src/Protocols/Http/Session/SessionHandlerInterface.php b/vendor/workerman/workerman/src/Protocols/Http/Session/SessionHandlerInterface.php new file mode 100644 index 0000000..03f6b35 --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Http/Session/SessionHandlerInterface.php @@ -0,0 +1,117 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols\Http\Session; + +interface SessionHandlerInterface +{ + /** + * Close the session + * @link http://php.net/manual/en/sessionhandlerinterface.close.php + * @return bool

+ * The return value (usually TRUE on success, FALSE on failure). + * Note this value is returned internally to PHP for processing. + *

+ * @since 5.4.0 + */ + public function close(): bool; + + /** + * Destroy a session + * @link http://php.net/manual/en/sessionhandlerinterface.destroy.php + * @param string $sessionId The session ID being destroyed. + * @return bool

+ * The return value (usually TRUE on success, FALSE on failure). + * Note this value is returned internally to PHP for processing. + *

+ * @since 5.4.0 + */ + public function destroy(string $sessionId): bool; + + /** + * Cleanup old sessions + * @link http://php.net/manual/en/sessionhandlerinterface.gc.php + * @param int $maxLifetime

+ * Sessions that have not updated for + * the last maxlifetime seconds will be removed. + *

+ * @return bool

+ * The return value (usually TRUE on success, FALSE on failure). + * Note this value is returned internally to PHP for processing. + *

+ * @since 5.4.0 + */ + public function gc(int $maxLifetime): bool; + + /** + * Initialize session + * @link http://php.net/manual/en/sessionhandlerinterface.open.php + * @param string $savePath The path where to store/retrieve the session. + * @param string $name The session name. + * @return bool

+ * The return value (usually TRUE on success, FALSE on failure). + * Note this value is returned internally to PHP for processing. + *

+ * @since 5.4.0 + */ + public function open(string $savePath, string $name): bool; + + + /** + * Read session data + * @link http://php.net/manual/en/sessionhandlerinterface.read.php + * @param string $sessionId The session id to read data for. + * @return string|false

+ * Returns an encoded string of the read data. + * If nothing was read, it must return false. + * Note this value is returned internally to PHP for processing. + *

+ * @since 5.4.0 + */ + public function read(string $sessionId): string|false; + + /** + * Write session data + * @link http://php.net/manual/en/sessionhandlerinterface.write.php + * @param string $sessionId The session id. + * @param string $sessionData

+ * The encoded session data. This data is the + * result of the PHP internally encoding + * the $SESSION superglobal to a serialized + * string and passing it as this parameter. + * Please note sessions use an alternative serialization method. + *

+ * @return bool

+ * The return value (usually TRUE on success, FALSE on failure). + * Note this value is returned internally to PHP for processing. + *

+ * @since 5.4.0 + */ + public function write(string $sessionId, string $sessionData): bool; + + /** + * Update session modify time. + * + * @see https://www.php.net/manual/en/class.sessionupdatetimestamphandlerinterface.php + * + * @param string $sessionId + * @param string $data Session Data. + * + * @return bool + */ + public function updateTimestamp(string $sessionId, string $data = ""): bool; + +} diff --git a/vendor/workerman/workerman/src/Protocols/ProtocolInterface.php b/vendor/workerman/workerman/src/Protocols/ProtocolInterface.php new file mode 100644 index 0000000..f60aa95 --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/ProtocolInterface.php @@ -0,0 +1,55 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols; + +use Workerman\Connection\ConnectionInterface; + +/** + * Protocol interface + */ +interface ProtocolInterface +{ + /** + * Check the integrity of the package. + * Please return the length of package. + * If length is unknown please return 0 that means waiting for more data. + * If the package has something wrong please return -1 the connection will be closed. + * + * @param string $buffer + * @param ConnectionInterface $connection + * @return int + */ + public static function input(string $buffer, ConnectionInterface $connection): int; + + /** + * Decode package and emit onMessage($message) callback, $message is the result that decode returned. + * + * @param string $buffer + * @param ConnectionInterface $connection + * @return mixed + */ + public static function decode(string $buffer, ConnectionInterface $connection): mixed; + + /** + * Encode package before sending to client. + * + * @param mixed $data + * @param ConnectionInterface $connection + * @return string + */ + public static function encode(mixed $data, ConnectionInterface $connection): string; +} diff --git a/vendor/workerman/workerman/src/Protocols/Text.php b/vendor/workerman/workerman/src/Protocols/Text.php new file mode 100644 index 0000000..df32ebe --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Text.php @@ -0,0 +1,76 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols; + +use Workerman\Connection\ConnectionInterface; +use function rtrim; +use function strlen; +use function strpos; + +/** + * Text Protocol. + */ +class Text +{ + /** + * Check the integrity of the package. + * + * @param string $buffer + * @param ConnectionInterface $connection + * @return int + */ + public static function input(string $buffer, ConnectionInterface $connection): int + { + // Judge whether the package length exceeds the limit. + if (isset($connection->maxPackageSize) && strlen($buffer) >= $connection->maxPackageSize) { + $connection->close(); + return 0; + } + // Find the position of "\n". + $pos = strpos($buffer, "\n"); + // No "\n", packet length is unknown, continue to wait for the data so return 0. + if ($pos === false) { + return 0; + } + // Return the current package length. + return $pos + 1; + } + + /** + * Encode. + * + * @param string $buffer + * @return string + */ + public static function encode(string $buffer): string + { + // Add "\n" + return $buffer . "\n"; + } + + /** + * Decode. + * + * @param string $buffer + * @return string + */ + public static function decode(string $buffer): string + { + // Remove "\n" + return rtrim($buffer, "\r\n"); + } +} \ No newline at end of file diff --git a/vendor/workerman/workerman/src/Protocols/Websocket.php b/vendor/workerman/workerman/src/Protocols/Websocket.php new file mode 100644 index 0000000..04b710f --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Websocket.php @@ -0,0 +1,483 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols; + +use Throwable; +use Workerman\Connection\ConnectionInterface; +use Workerman\Connection\TcpConnection; +use Workerman\Protocols\Http\Request; +use Workerman\Worker; +use function base64_encode; +use function chr; +use function deflate_add; +use function deflate_init; +use function floor; +use function inflate_add; +use function inflate_init; +use function is_scalar; +use function ord; +use function pack; +use function preg_match; +use function sha1; +use function str_repeat; +use function stripos; +use function strlen; +use function strpos; +use function substr; +use function unpack; +use const ZLIB_DEFAULT_STRATEGY; +use const ZLIB_ENCODING_RAW; + +/** + * WebSocket protocol. + */ +class Websocket +{ + /** + * Websocket blob type. + * + * @var string + */ + public const BINARY_TYPE_BLOB = "\x81"; + + /** + * Websocket blob type. + * + * @var string + */ + const BINARY_TYPE_BLOB_DEFLATE = "\xc1"; + + /** + * Websocket arraybuffer type. + * + * @var string + */ + public const BINARY_TYPE_ARRAYBUFFER = "\x82"; + + /** + * Websocket arraybuffer type. + * + * @var string + */ + const BINARY_TYPE_ARRAYBUFFER_DEFLATE = "\xc2"; + + private const ZLIB_INIT_OPTIONS = [ + ZLIB_ENCODING_RAW, + [ + 'level' => -1, + 'memory' => 8, + 'window' => 15, + 'strategy' => ZLIB_DEFAULT_STRATEGY + ] + ]; + + public static function input(string $buffer, TcpConnection $connection): int + { + // Receive length. + $recvLen = strlen($buffer); + // We need more data. + if ($recvLen < 6) { + return 0; + } + + // Has not yet completed the handshake. + if (empty($connection->context->websocketHandshake)) { + return static::dealHandshake($buffer, $connection); + } + + // Buffer websocket frame data. + if ($connection->context->websocketCurrentFrameLength) { + // We need more frame data. + if ($connection->context->websocketCurrentFrameLength > $recvLen) { + // Return 0, because it is not clear the full packet length, waiting for the frame of fin=1. + return 0; + } + } else { + $firstByte = ord($buffer[0]); + $secondByte = ord($buffer[1]); + $dataLen = $secondByte & 127; + $isFinFrame = $firstByte >> 7; + $masked = $secondByte >> 7; + + if (!$masked) { + Worker::safeEcho("frame not masked so close the connection\n"); + $connection->close(); + return 0; + } + + $opcode = $firstByte & 0xf; + switch ($opcode) { + case 0x0: + // Blob type. + case 0x1: + // Arraybuffer type. + case 0x2: + // Ping package. + case 0x9: + // Pong package. + case 0xa: + break; + // Close package. + case 0x8: + // Try to emit onWebSocketClose callback. + $closeCb = $connection->onWebSocketClose ?? $connection->worker->onWebSocketClose ?? false; + if ($closeCb) { + try { + $closeCb($connection); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } // Close connection. + else { + $connection->close("\x88\x02\x03\xe8", true); + } + return 0; + // Wrong opcode. + default : + Worker::safeEcho("error opcode $opcode and close websocket connection. Buffer:" . bin2hex($buffer) . "\n"); + $connection->close(); + return 0; + } + + // Calculate packet length. + $headLen = 6; + if ($dataLen === 126) { + $headLen = 8; + if ($headLen > $recvLen) { + return 0; + } + $dataLen = unpack('nn/ntotal_len', $buffer)['total_len']; + } else { + if ($dataLen === 127) { + $headLen = 14; + if ($headLen > $recvLen) { + return 0; + } + $arr = unpack('n/N2c', $buffer); + $dataLen = $arr['c1'] * 4294967296 + $arr['c2']; + } + } + $currentFrameLength = $headLen + $dataLen; + + $totalPackageSize = strlen($connection->context->websocketDataBuffer) + $currentFrameLength; + if ($totalPackageSize > $connection->maxPackageSize) { + Worker::safeEcho("error package. package_length=$totalPackageSize\n"); + $connection->close(); + return 0; + } + + if ($isFinFrame) { + if ($opcode === 0x9) { + if ($recvLen >= $currentFrameLength) { + $pingData = static::decode(substr($buffer, 0, $currentFrameLength), $connection); + $connection->consumeRecvBuffer($currentFrameLength); + $tmpConnectionType = $connection->websocketType ?? static::BINARY_TYPE_BLOB; + $connection->websocketType = "\x8a"; + $pingCb = $connection->onWebSocketPing ?? $connection->worker->onWebSocketPing ?? false; + if ($pingCb) { + try { + $pingCb($connection, $pingData); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } else { + $connection->send($pingData); + } + $connection->websocketType = $tmpConnectionType; + if ($recvLen > $currentFrameLength) { + return static::input(substr($buffer, $currentFrameLength), $connection); + } + } + return 0; + } + + if ($opcode === 0xa) { + if ($recvLen >= $currentFrameLength) { + $pongData = static::decode(substr($buffer, 0, $currentFrameLength), $connection); + $connection->consumeRecvBuffer($currentFrameLength); + $tmpConnectionType = $connection->websocketType ?? static::BINARY_TYPE_BLOB; + $connection->websocketType = "\x8a"; + // Try to emit onWebSocketPong callback. + $pongCb = $connection->onWebSocketPong ?? $connection->worker->onWebSocketPong ?? false; + if ($pongCb) { + try { + $pongCb($connection, $pongData); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + $connection->websocketType = $tmpConnectionType; + if ($recvLen > $currentFrameLength) { + return static::input(substr($buffer, $currentFrameLength), $connection); + } + } + return 0; + } + return $currentFrameLength; + } + + $connection->context->websocketCurrentFrameLength = $currentFrameLength; + } + + // Received just a frame length data. + if ($connection->context->websocketCurrentFrameLength === $recvLen) { + static::decode($buffer, $connection); + $connection->consumeRecvBuffer($connection->context->websocketCurrentFrameLength); + $connection->context->websocketCurrentFrameLength = 0; + return 0; + } + + // The length of the received data is greater than the length of a frame. + if ($connection->context->websocketCurrentFrameLength < $recvLen) { + static::decode(substr($buffer, 0, $connection->context->websocketCurrentFrameLength), $connection); + $connection->consumeRecvBuffer($connection->context->websocketCurrentFrameLength); + $currentFrameLength = $connection->context->websocketCurrentFrameLength; + $connection->context->websocketCurrentFrameLength = 0; + // Continue to read next frame. + return static::input(substr($buffer, $currentFrameLength), $connection); + } + + // The length of the received data is less than the length of a frame. + return 0; + } + + public static function encode(mixed $buffer, TcpConnection $connection): string + { + if (!is_scalar($buffer)) { + $buffer = json_encode($buffer, JSON_UNESCAPED_UNICODE); + } + + $connection->websocketType ??= static::BINARY_TYPE_BLOB; + + if (ord($connection->websocketType) & 64) { + $buffer = static::deflate($connection, $buffer); + } + + $firstByte = $connection->websocketType; + $len = strlen($buffer); + + $encodeBuffer = match(true) { + $len <= 125 => $firstByte . chr($len) . $buffer, + $len <= 65535 => $firstByte . chr(126) . pack("n", $len) . $buffer, + default => $firstByte . chr(127) . pack("xxxxN", $len) . $buffer, + }; + + // Handshake not completed so temporary buffer websocket data waiting for send. + if (empty($connection->context->websocketHandshake)) { + if (empty($connection->context->tmpWebsocketData)) { + $connection->context->tmpWebsocketData = ''; + } + // If buffer has already full then discard the current package. + if (strlen($connection->context->tmpWebsocketData) > $connection->maxSendBufferSize) { + if ($connection->onError) { + try { + ($connection->onError)($connection, ConnectionInterface::SEND_FAIL, 'send buffer full and drop package'); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + return ''; + } + $connection->context->tmpWebsocketData .= $encodeBuffer; + // Check buffer is full. + if ($connection->onBufferFull && $connection->maxSendBufferSize <= strlen($connection->context->tmpWebsocketData)) { + try { + ($connection->onBufferFull)($connection); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + // Return empty string. + return ''; + } + + return $encodeBuffer; + } + + public static function decode(string $buffer, TcpConnection $connection): string + { + $firstByte = ord($buffer[0]); + $secondByte = ord($buffer[1]); + $len = $secondByte & 127; + $isFinFrame = (bool)($firstByte >> 7); + $rsv1 = 64 === ($firstByte & 64); + + [$masks, $data] = match(true) { + $len === 126 => [substr($buffer, 4, 4), substr($buffer, 8)], + $len === 127 => [substr($buffer, 10, 4), substr($buffer, 14)], + default => [substr($buffer, 2, 4), substr($buffer, 6)], + }; + + $dataLength = strlen($data); + $masks = str_repeat($masks, (int)floor($dataLength / 4)) . substr($masks, 0, $dataLength % 4); + $decoded = $data ^ $masks; + if ($connection->context->websocketCurrentFrameLength) { + $connection->context->websocketDataBuffer .= $decoded; + if ($rsv1) { + return static::inflate($connection, $connection->context->websocketDataBuffer, $isFinFrame); + } + return $connection->context->websocketDataBuffer; + } + if ($connection->context->websocketDataBuffer !== '') { + $decoded = $connection->context->websocketDataBuffer . $decoded; + $connection->context->websocketDataBuffer = ''; + } + if ($rsv1) { + return static::inflate($connection, $decoded, $isFinFrame); + } + return $decoded; + } + + protected static function inflate(TcpConnection $connection, string $buffer, bool $isFinFrame): false|string + { + $connection->context->inflator ??= inflate_init(...self::ZLIB_INIT_OPTIONS); + + if ($isFinFrame) { + $buffer .= "\x00\x00\xff\xff"; + } + $result = inflate_add($connection->context->inflator, $buffer); + // Guard against decompression bomb: check inflated size against maxPackageSize. + if ($result !== false && strlen($result) > $connection->maxPackageSize) { + Worker::safeEcho("WebSocket inflate data exceeds maxPackageSize limit\n"); + $connection->close(); + return false; + } + return $result; + } + + protected static function deflate(TcpConnection $connection, string $buffer): false|string + { + + $connection->context->deflator ??= deflate_init(...self::ZLIB_INIT_OPTIONS); + + return substr(deflate_add($connection->context->deflator, $buffer), 0, -4); + } + + /** + * Websocket handshake. + * + */ + public static function dealHandshake(string $buffer, TcpConnection $connection): int + { + $HTTP_400 = "HTTP/1.1 400 Bad Request\r\n\r\n

400 Bad Request


workerman
"; + + // HTTP protocol. + if (!str_starts_with($buffer, 'GET')) { + // Bad websocket handshake request. + $connection->close($HTTP_400, true); + return 0; + } + + // Find \r\n\r\n. + $headerEndPos = strpos($buffer, "\r\n\r\n"); + if (!$headerEndPos) { + return 0; + } + $headerLength = $headerEndPos + 4; + + // Check WebSocket version - RFC 6455 Section 4.4 + if (preg_match("/Sec-WebSocket-Version: *(.*?)\r\n/i", $buffer, $match)) { + if($match[1] !== '13') { + $_426 = "HTTP/1.1 426 Upgrade Required\r\n" + . "Connection: Upgrade\r\n" + . "Upgrade: WebSocket\r\n" + . "Sec-WebSocket-Version: 13\r\n\r\n"; + + $connection->close($_426, true); + return 0; + } + } else { + $connection->close( + "HTTP/1.1 400 Bad Request\r\nSec-WebSocket-Version: 13\r\n\r\n", true); + return 0; + } + + // Get Sec-WebSocket-Key. + if (preg_match("/Sec-WebSocket-Key: *(.*?)\r\n/i", $buffer, $match)) { + $SecWebSocketKey = $match[1]; + } else { + $connection->close($HTTP_400, true); + return 0; + } + // Calculation websocket key. + $newKey = base64_encode(sha1($SecWebSocketKey . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true)); + // Handshake response data. + $handshakeMessage = "HTTP/1.1 101 Switching Protocol\r\n" + . "Upgrade: websocket\r\n" + . "Sec-WebSocket-Version: 13\r\n" + . "Connection: Upgrade\r\n" + . "Sec-WebSocket-Accept: $newKey\r\n"; + + // Websocket data buffer. + $connection->context->websocketDataBuffer = ''; + // Current websocket frame length. + $connection->context->websocketCurrentFrameLength = 0; + // Current websocket frame data. + $connection->context->websocketCurrentFrameBuffer = ''; + // Consume handshake data. + $connection->consumeRecvBuffer($headerLength); + // Request from buffer + $request = new Request($buffer); + + // Try to emit onWebSocketConnect callback. + $onWebsocketConnect = $connection->onWebSocketConnect ?? $connection->worker->onWebSocketConnect ?? false; + if ($onWebsocketConnect) { + try { + $onWebsocketConnect($connection, $request); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + + // blob or arraybuffer + $connection->websocketType ??= static::BINARY_TYPE_BLOB; + + if ($connection->headers) { + foreach ($connection->headers as $header) { + if (strpbrk($header, "\r\n") !== false) { + continue; + } + $handshakeMessage .= "$header\r\n"; + } + } + $handshakeMessage .= "\r\n"; + // Send handshake response. + $connection->send($handshakeMessage, true); + // Mark handshake complete. + $connection->context->websocketHandshake = true; + + // Try to emit onWebSocketConnected callback. + $onWebsocketConnected = $connection->onWebSocketConnected ?? $connection->worker->onWebSocketConnected ?? false; + if ($onWebsocketConnected) { + try { + $onWebsocketConnected($connection, $request); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + + // There are data waiting to be sent. + if (!empty($connection->context->tmpWebsocketData)) { + $connection->send($connection->context->tmpWebsocketData, true); + $connection->context->tmpWebsocketData = ''; + } + if (strlen($buffer) > $headerLength) { + return static::input(substr($buffer, $headerLength), $connection); + } + return 0; + } +} diff --git a/vendor/workerman/workerman/src/Protocols/Ws.php b/vendor/workerman/workerman/src/Protocols/Ws.php new file mode 100644 index 0000000..69bf77b --- /dev/null +++ b/vendor/workerman/workerman/src/Protocols/Ws.php @@ -0,0 +1,475 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman\Protocols; + +use Throwable; +use Workerman\Connection\AsyncTcpConnection; +use Workerman\Connection\ConnectionInterface; +use Workerman\Protocols\Http\Response; +use Workerman\Timer; +use Workerman\Worker; +use function base64_encode; +use function bin2hex; +use function explode; +use function floor; +use function ord; +use function pack; +use function preg_match; +use function sha1; +use function str_repeat; +use function strlen; +use function strpos; +use function substr; +use function trim; +use function unpack; + +/** + * Websocket protocol for client. + */ +class Ws +{ + /** + * Websocket blob type. + * + * @var string + */ + public const BINARY_TYPE_BLOB = "\x81"; + + /** + * Websocket arraybuffer type. + * + * @var string + */ + public const BINARY_TYPE_ARRAYBUFFER = "\x82"; + + public static function input(string $buffer, AsyncTcpConnection $connection): int + { + if (!isset($connection->context->handshakeStep)) { + Worker::safeEcho("recv data before handshake. Buffer:" . bin2hex($buffer) . "\n"); + return -1; + } + // Recv handshake response + if ($connection->context->handshakeStep === 1) { + return self::dealHandshake($buffer, $connection); + } + $recvLen = strlen($buffer); + if ($recvLen < 2) { + return 0; + } + // Buffer websocket frame data. + if ($connection->context->websocketCurrentFrameLength) { + // We need more frame data. + if ($connection->context->websocketCurrentFrameLength > $recvLen) { + // Return 0, because it is not clear the full packet length, waiting for the frame of fin=1. + return 0; + } + } else { + + $firstByte = ord($buffer[0]); + $secondByte = ord($buffer[1]); + $dataLen = $secondByte & 127; + $isFinFrame = $firstByte >> 7; + $masked = $secondByte >> 7; + + if ($masked) { + Worker::safeEcho("frame masked so close the connection\n"); + $connection->close(); + return 0; + } + + $opcode = $firstByte & 0xf; + + switch ($opcode) { + case 0x0: + // Blob type. + case 0x1: + // Arraybuffer type. + case 0x2: + // Ping package. + case 0x9: + // Pong package. + case 0xa: + break; + // Close package. + case 0x8: + // Try to emit onWebSocketClose callback. + if (isset($connection->onWebSocketClose)) { + try { + ($connection->onWebSocketClose)($connection, self::decode($buffer, $connection)); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } else { // Close connection. + $connection->close(); + } + + return 0; + // Wrong opcode. + default : + Worker::safeEcho("error opcode $opcode and close websocket connection. Buffer:" . $buffer . "\n"); + $connection->close(); + return 0; + } + // Calculate packet length. + if ($dataLen === 126) { + if (strlen($buffer) < 4) { + return 0; + } + $currentFrameLength = unpack('nn/ntotal_len', $buffer)['total_len'] + 4; + } else if ($dataLen === 127) { + if (strlen($buffer) < 10) { + return 0; + } + $arr = unpack('n/N2c', $buffer); + $currentFrameLength = $arr['c1'] * 4294967296 + $arr['c2'] + 10; + } else { + $currentFrameLength = $dataLen + 2; + } + + $totalPackageSize = strlen($connection->context->websocketDataBuffer) + $currentFrameLength; + if ($totalPackageSize > $connection->maxPackageSize) { + Worker::safeEcho("error package. package_length=$totalPackageSize\n"); + $connection->close(); + return 0; + } + + if ($isFinFrame) { + if ($opcode === 0x9) { + if ($recvLen >= $currentFrameLength) { + $pingData = static::decode(substr($buffer, 0, $currentFrameLength), $connection); + $connection->consumeRecvBuffer($currentFrameLength); + $tmpConnectionType = $connection->websocketType ?? static::BINARY_TYPE_BLOB; + $connection->websocketType = "\x8a"; + if (isset($connection->onWebSocketPing)) { + try { + ($connection->onWebSocketPing)($connection, $pingData); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } else { + $connection->send($pingData); + } + $connection->websocketType = $tmpConnectionType; + if ($recvLen > $currentFrameLength) { + return static::input(substr($buffer, $currentFrameLength), $connection); + } + } + return 0; + + } + + if ($opcode === 0xa) { + if ($recvLen >= $currentFrameLength) { + $pongData = static::decode(substr($buffer, 0, $currentFrameLength), $connection); + $connection->consumeRecvBuffer($currentFrameLength); + $tmpConnectionType = $connection->websocketType ?? static::BINARY_TYPE_BLOB; + $connection->websocketType = "\x8a"; + // Try to emit onWebSocketPong callback. + if (isset($connection->onWebSocketPong)) { + try { + ($connection->onWebSocketPong)($connection, $pongData); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + $connection->websocketType = $tmpConnectionType; + if ($recvLen > $currentFrameLength) { + return static::input(substr($buffer, $currentFrameLength), $connection); + } + } + return 0; + } + return $currentFrameLength; + } + + $connection->context->websocketCurrentFrameLength = $currentFrameLength; + } + // Received just a frame length data. + if ($connection->context->websocketCurrentFrameLength === $recvLen) { + self::decode($buffer, $connection); + $connection->consumeRecvBuffer($connection->context->websocketCurrentFrameLength); + $connection->context->websocketCurrentFrameLength = 0; + return 0; + } // The length of the received data is greater than the length of a frame. + else if ($connection->context->websocketCurrentFrameLength < $recvLen) { + self::decode(substr($buffer, 0, $connection->context->websocketCurrentFrameLength), $connection); + $connection->consumeRecvBuffer($connection->context->websocketCurrentFrameLength); + $currentFrameLength = $connection->context->websocketCurrentFrameLength; + $connection->context->websocketCurrentFrameLength = 0; + // Continue to read next frame. + return self::input(substr($buffer, $currentFrameLength), $connection); + } // The length of the received data is less than the length of a frame. + + return 0; + + } + + /** + * Websocket encode. + * + * @param string $payload + * @param AsyncTcpConnection $connection + * @return string + * @throws Throwable + */ + public static function encode(string $payload, AsyncTcpConnection $connection): string + { + $connection->websocketType ??= self::BINARY_TYPE_BLOB; + + $connection->websocketOrigin ??= null; + $connection->websocketClientProtocol ??= null; + if (!isset($connection->context->handshakeStep)) { + static::sendHandshake($connection); + } + + $maskKey = "\x00\x00\x00\x00"; + $length = strlen($payload); + + $head = match(true) { + $length < 126 => chr(0x80 | $length), + $length < 0xFFFF => chr(0x80 | 126) . pack("n", $length), + default => chr(0x80 | 127) . pack("N", 0) . pack("N", $length), + }; + + $frame = $connection->websocketType . $head . $maskKey; + // append payload to frame: + $maskKey = str_repeat($maskKey, (int)floor($length / 4)) . substr($maskKey, 0, $length % 4); + $frame .= $payload ^ $maskKey; + if ($connection->context->handshakeStep === 1) { + // If buffer has already full then discard the current package. + if (strlen($connection->context->tmpWebsocketData) > $connection->maxSendBufferSize) { + if ($connection->onError) { + try { + ($connection->onError)($connection, ConnectionInterface::SEND_FAIL, 'send buffer full and drop package'); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + return ''; + } + $connection->context->tmpWebsocketData .= $frame; + // Check buffer is full. + if ($connection->onBufferFull && $connection->maxSendBufferSize <= strlen($connection->context->tmpWebsocketData)) { + try { + ($connection->onBufferFull)($connection); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + return ''; + } + return $frame; + } + + /** + * Websocket decode. + * + * @param string $bytes + * @param AsyncTcpConnection $connection + * @return string + */ + public static function decode(string $bytes, AsyncTcpConnection $connection): string + { + $decodedData = match(ord($bytes[1])) { // data length + 126 => substr($bytes, 4), + 127 => substr($bytes, 10), + default => substr($bytes, 2), + }; + + if ($connection->context->websocketCurrentFrameLength) { + return $connection->context->websocketDataBuffer .= $decodedData; + } + + if ($connection->context->websocketDataBuffer !== '') { + $decodedData = $connection->context->websocketDataBuffer . $decodedData; + $connection->context->websocketDataBuffer = ''; + } + return $decodedData; + } + + /** + * Send websocket handshake data. + * + * @param AsyncTcpConnection $connection + * @return void + * @throws Throwable + */ + public static function onConnect(AsyncTcpConnection $connection): void + { + $connection->websocketOrigin = $connection->websocketOrigin ?? null; + $connection->websocketClientProtocol = $connection->websocketClientProtocol ?? null; + static::sendHandshake($connection); + } + + /** + * Clean + * + * @param AsyncTcpConnection $connection + */ + public static function onClose(AsyncTcpConnection $connection): void + { + unset($connection->context->handshakeStep); + $connection->context->websocketCurrentFrameLength = 0; + $connection->context->tmpWebsocketData = ''; + $connection->context->websocketDataBuffer = ''; + if (!empty($connection->context->websocketPingTimer)) { + Timer::del($connection->context->websocketPingTimer); + $connection->context->websocketPingTimer = null; + } + } + + /** + * Send websocket handshake. + * + * @throws Throwable + */ + public static function sendHandshake(AsyncTcpConnection $connection): void + { + if (!empty($connection->context->handshakeStep)) { + return; + } + // Get Host. + $port = $connection->getRemotePort(); + $host = match($port) { + 80, 443 => $connection->getRemoteHost(), + default => $connection->getRemoteHost() . ":$port", + }; + // Handshake header. + $connection->context->websocketSecKey = base64_encode(random_bytes(16)); + $userHeader = $connection->headers ?? null; + $userHeaderStr = ''; + if (!empty($userHeader)) { + foreach ($userHeader as $k => $v) { + // Skip unsafe header names or values containing CR/LF + if (strpbrk((string)$k, ":\r\n") !== false) { + continue; + } + if (strpbrk((string)$v, "\r\n") !== false) { + continue; + } + $userHeaderStr .= "$k: $v\r\n"; + } + $userHeaderStr = $userHeaderStr !== '' ? "\r\n" . trim($userHeaderStr) : ''; + } + $requestUri = str_replace(["\r", "\n"], '', $connection->getRemoteURI()); + // Sanitize Origin and Sec-WebSocket-Protocol + $origin = $connection->websocketOrigin ?? null; + $origin = $origin !== null ? str_replace(["\r", "\n"], '', $origin) : null; + $clientProtocol = $connection->websocketClientProtocol ?? null; + $clientProtocol = $clientProtocol !== null ? str_replace(["\r", "\n"], '', $clientProtocol) : null; + $header = 'GET ' . $requestUri . " HTTP/1.1\r\n" . + (!preg_match("/\nHost:/i", $userHeaderStr) ? "Host: $host\r\n" : '') . + "Connection: Upgrade\r\n" . + "Upgrade: websocket\r\n" . + ($origin ? "Origin: " . $origin . "\r\n" : '') . + ($clientProtocol ? "Sec-WebSocket-Protocol: $clientProtocol\r\n" : '') . + "Sec-WebSocket-Version: 13\r\n" . + "Sec-WebSocket-Key: " . $connection->context->websocketSecKey . "$userHeaderStr\r\n\r\n"; + $connection->send($header, true); + $connection->context->handshakeStep = 1; + $connection->context->websocketCurrentFrameLength = 0; + $connection->context->websocketDataBuffer = ''; + $connection->context->tmpWebsocketData = ''; + } + + /** + * Websocket handshake. + * + * @param string $buffer + * @param AsyncTcpConnection $connection + * @return bool|int + */ + public static function dealHandshake(string $buffer, AsyncTcpConnection $connection): bool|int + { + $pos = strpos($buffer, "\r\n\r\n"); + if (!$pos) { + return 0; + } + + //checking Sec-WebSocket-Accept + if (preg_match("/Sec-WebSocket-Accept: *(.*?)\r\n/i", $buffer, $match)) { + if ($match[1] !== base64_encode(sha1($connection->context->websocketSecKey . "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", true))) { + Worker::safeEcho("Sec-WebSocket-Accept not match. Header:\n" . substr($buffer, 0, $pos) . "\n"); + $connection->close(); + return 0; + } + } else { + Worker::safeEcho("Sec-WebSocket-Accept not found. Header:\n" . substr($buffer, 0, $pos) . "\n"); + $connection->close(); + return 0; + } + + // handshake complete + $connection->context->handshakeStep = 2; + $handshakeResponseLength = $pos + 4; + $buffer = substr($buffer, 0, $handshakeResponseLength); + $response = static::parseResponse($buffer); + // Try to emit onWebSocketConnect callback. + if (isset($connection->onWebSocketConnect)) { + try { + ($connection->onWebSocketConnect)($connection, $response); + } catch (Throwable $e) { + Worker::stopAll(250, $e); + } + } + // Headbeat. + if (!empty($connection->websocketPingInterval)) { + $connection->context->websocketPingTimer = Timer::add($connection->websocketPingInterval, function () use ($connection) { + if (false === $connection->send(pack('H*', '898000000000'), true)) { + Timer::del($connection->context->websocketPingTimer); + $connection->context->websocketPingTimer = null; + } + }); + } + + $connection->consumeRecvBuffer($handshakeResponseLength); + if (!empty($connection->context->tmpWebsocketData)) { + $connection->send($connection->context->tmpWebsocketData, true); + $connection->context->tmpWebsocketData = ''; + } + if (strlen($buffer) > $handshakeResponseLength) { + return self::input(substr($buffer, $handshakeResponseLength), $connection); + } + + return 0; + } + + /** + * Parse response. + * + * @param string $buffer + * @return Response + */ + protected static function parseResponse(string $buffer): Response + { + [$http_header, ] = explode("\r\n\r\n", $buffer, 2); + $header_data = explode("\r\n", $http_header); + [$protocol, $status, $phrase] = explode(' ', $header_data[0], 3); + $protocolVersion = substr($protocol, 5); + unset($header_data[0]); + $headers = []; + foreach ($header_data as $content) { + // \r\n\r\n + if (empty($content)) { + continue; + } + [$key, $value] = explode(':', $content, 2); + $headers[$key] = trim($value); + } + return (new Response())->withStatus((int)$status, $phrase)->withHeaders($headers)->withProtocolVersion($protocolVersion); + } +} diff --git a/vendor/workerman/workerman/src/Timer.php b/vendor/workerman/workerman/src/Timer.php new file mode 100644 index 0000000..0d5c9ad --- /dev/null +++ b/vendor/workerman/workerman/src/Timer.php @@ -0,0 +1,273 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman; + +use RuntimeException; +use Throwable; +use Workerman\Events\EventInterface; +use Workerman\Events\Fiber; +use Workerman\Events\Swoole; +use Revolt\EventLoop; +use Swoole\Coroutine\System; +use function function_exists; +use function pcntl_alarm; +use function pcntl_signal; +use function time; +use const PHP_INT_MAX; +use const SIGALRM; + +/** + * Timer. + */ +class Timer +{ + /** + * Tasks that based on ALARM signal. + * [ + * run_time => [[$func, $args, $persistent, time_interval],[$func, $args, $persistent, time_interval],..]], + * run_time => [[$func, $args, $persistent, time_interval],[$func, $args, $persistent, time_interval],..]], + * .. + * ] + * + * @var array + */ + protected static array $tasks = []; + + /** + * Event + * + * @var ?EventInterface + */ + protected static ?EventInterface $event = null; + + /** + * Timer id + * + * @var int + */ + protected static int $timerId = 0; + + /** + * Timer status + * [ + * timer_id1 => bool, + * timer_id2 => bool, + * ...................., + * ] + * + * @var array + */ + protected static array $status = []; + + /** + * Init. + * + * @param EventInterface|null $event + * @return void + */ + public static function init(?EventInterface $event = null): void + { + if ($event) { + self::$event = $event; + return; + } + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGALRM, self::signalHandle(...), false); + } + } + + /** + * Repeat. + * + * @param float $timeInterval + * @param callable $func + * @param array $args + * @return int + */ + public static function repeat(float $timeInterval, callable $func, array $args = []): int + { + return self::$event->repeat($timeInterval, $func, $args); + } + + /** + * Delay. + * + * @param float $timeInterval + * @param callable $func + * @param array $args + * @return int + */ + public static function delay(float $timeInterval, callable $func, array $args = []): int + { + return self::$event->delay($timeInterval, $func, $args); + } + + /** + * ALARM signal handler. + * + * @return void + */ + public static function signalHandle(): void + { + if (!self::$event) { + pcntl_alarm(1); + self::tick(); + } + } + + /** + * Add a timer. + * + * @param float $timeInterval + * @param callable $func + * @param null|array $args + * @param bool $persistent + * @return int + */ + public static function add(float $timeInterval, callable $func, ?array $args = [], bool $persistent = true): int + { + if ($timeInterval < 0) { + throw new RuntimeException('$timeInterval can not less than 0'); + } + + if ($args === null) { + $args = []; + } + + if (self::$event) { + return $persistent ? self::$event->repeat($timeInterval, $func, $args) : self::$event->delay($timeInterval, $func, $args); + } + + // If not workerman runtime just return. + if (!Worker::getAllWorkers()) { + throw new RuntimeException('Timer can only be used in workerman running environment'); + } + + if (empty(self::$tasks)) { + pcntl_alarm(1); + } + + $runTime = (int)floor(time() + $timeInterval); + if (!isset(self::$tasks[$runTime])) { + self::$tasks[$runTime] = []; + } + + self::$timerId = self::$timerId == PHP_INT_MAX ? 1 : ++self::$timerId; + self::$status[self::$timerId] = true; + self::$tasks[$runTime][self::$timerId] = [$func, (array)$args, $persistent, $timeInterval]; + + return self::$timerId; + } + + /** + * Coroutine sleep. + * + * @param float $delay + * @return void + */ + public static function sleep(float $delay): void + { + switch (Worker::$eventLoopClass) { + // Fiber + case Fiber::class: + $suspension = EventLoop::getSuspension(); + static::add($delay, function () use ($suspension) { + $suspension->resume(); + }, null, false); + $suspension->suspend(); + return; + // Swoole + case Swoole::class: + System::sleep($delay); + return; + } + usleep((int)($delay * 1000 * 1000)); + } + + /** + * Tick. + * + * @return void + */ + protected static function tick(): void + { + if (empty(self::$tasks)) { + pcntl_alarm(0); + return; + } + $timeNow = time(); + foreach (self::$tasks as $runTime => $taskData) { + if ($timeNow >= $runTime) { + foreach ($taskData as $index => $oneTask) { + $taskFunc = $oneTask[0]; + $taskArgs = $oneTask[1]; + $persistent = $oneTask[2]; + $timeInterval = $oneTask[3]; + try { + $taskFunc(...$taskArgs); + } catch (Throwable $e) { + Worker::safeEcho((string)$e); + } + if ($persistent && !empty(self::$status[$index])) { + $newRunTime = (int)floor(time() + $timeInterval); + if (!isset(self::$tasks[$newRunTime])) { + self::$tasks[$newRunTime] = []; + } + self::$tasks[$newRunTime][$index] = [$taskFunc, (array)$taskArgs, $persistent, $timeInterval]; + } + } + unset(self::$tasks[$runTime]); + } + } + } + + /** + * Remove a timer. + * + * @param int $timerId + * @return bool + */ + public static function del(int $timerId): bool + { + if (self::$event) { + return self::$event->offDelay($timerId); + } + foreach (self::$tasks as $runTime => $taskData) { + if (array_key_exists($timerId, $taskData)) { + unset(self::$tasks[$runTime][$timerId]); + } + } + if (array_key_exists($timerId, self::$status)) { + unset(self::$status[$timerId]); + } + return true; + } + + /** + * Remove all timers. + * + * @return void + */ + public static function delAll(): void + { + self::$tasks = self::$status = []; + if (function_exists('pcntl_alarm')) { + pcntl_alarm(0); + } + self::$event?->deleteAllTimer(); + } +} diff --git a/vendor/workerman/workerman/src/Worker.php b/vendor/workerman/workerman/src/Worker.php new file mode 100644 index 0000000..b4e0a85 --- /dev/null +++ b/vendor/workerman/workerman/src/Worker.php @@ -0,0 +1,2831 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +declare(strict_types=1); + +namespace Workerman; + +use AllowDynamicProperties; +use Exception; +use RuntimeException; +use stdClass; +use Stringable; +use Throwable; +use Workerman\Connection\ConnectionInterface; +use Workerman\Connection\TcpConnection; +use Workerman\Connection\UdpConnection; +use Workerman\Coroutine; +use Workerman\Coroutine\Context; +use Workerman\Events\Event; +use Workerman\Events\EventInterface; +use Workerman\Events\Fiber; +use Workerman\Events\Select; +use Workerman\Events\Swoole; +use Workerman\Events\Swow; +use function defined; +use function function_exists; +use function is_resource; +use function method_exists; +use function restore_error_handler; +use function set_error_handler; +use function stream_socket_accept; +use function stream_socket_recvfrom; +use function substr; +use function array_walk; +use function get_class; +use const DIRECTORY_SEPARATOR; +use const PHP_SAPI; +use const PHP_VERSION; +use const STDOUT; + +/** + * Worker class + * A container for listening ports + */ +#[AllowDynamicProperties] +class Worker +{ + /** + * Version. + * + * @var string + */ + final public const VERSION = '5.2.0'; + + /** + * Status initial. + * + * @var int + */ + public const STATUS_INITIAL = 0; + + /** + * Status starting. + * + * @var int + */ + public const STATUS_STARTING = 1; + + /** + * Status running. + * + * @var int + */ + public const STATUS_RUNNING = 2; + + /** + * Status shutdown. + * + * @var int + */ + public const STATUS_SHUTDOWN = 4; + + /** + * Status reloading. + * + * @var int + */ + public const STATUS_RELOADING = 8; + + /** + * Default backlog. Backlog is the maximum length of the queue of pending connections. + * + * @var int + */ + public const DEFAULT_BACKLOG = 102400; + + /** + * The safe distance for columns adjacent + * + * @var int + */ + public const UI_SAFE_LENGTH = 4; + + /** + * Worker id. + * + * @var int + */ + public int $id = 0; + + /** + * Name of the worker processes. + * + * @var string + */ + public string $name = 'none'; + + /** + * Number of worker processes. + * + * @var int + */ + public int $count = 1; + + /** + * Unix user of processes, needs appropriate privileges (usually root). + * + * @var string + */ + public string $user = ''; + + /** + * Unix group of processes, needs appropriate privileges (usually root). + * + * @var string + */ + public string $group = ''; + + /** + * reloadable. + * + * @var bool + */ + public bool $reloadable = true; + + /** + * reuse port. + * + * @var bool + */ + public bool $reusePort = false; + + /** + * Emitted when worker processes is starting. + * + * @var ?callable + */ + public $onWorkerStart = null; + + /** + * Emitted when a socket connection is successfully established. + * + * @var ?callable + */ + public $onConnect = null; + + /** + * Emitted before websocket handshake (Only works when protocol is ws). + * + * @var ?callable + */ + public $onWebSocketConnect = null; + + /** + * Emitted after websocket handshake (Only works when protocol is ws). + * + * @var ?callable + */ + public $onWebSocketConnected = null; + + /** + * Emitted when data is received. + * + * @var ?callable + */ + public $onMessage = null; + + /** + * Emitted when the other end of the socket sends a FIN packet. + * + * @var ?callable + */ + public $onClose = null; + + /** + * Emitted when an error occurs with connection. + * + * @var ?callable + */ + public $onError = null; + + /** + * Emitted when the send buffer becomes full. + * + * @var ?callable + */ + public $onBufferFull = null; + + /** + * Emitted when the send buffer becomes empty. + * + * @var ?callable + */ + public $onBufferDrain = null; + + /** + * Emitted when worker processes has stopped. + * + * @var ?callable + */ + public $onWorkerStop = null; + + /** + * Emitted when worker processes receives reload signal. + * + * @var ?callable + */ + public $onWorkerReload = null; + + /** + * Transport layer protocol. + * + * @var string + */ + public string $transport = 'tcp'; + + /** + * Store all connections of clients. + * + * @internal Framework internal API + * + * @var TcpConnection[] + */ + public array $connections = []; + + /** + * Application layer protocol. + * + * @var ?string + */ + public ?string $protocol = null; + + /** + * Pause accept new connections or not. + * + * @var bool + */ + protected ?bool $pauseAccept = null; + + /** + * Is worker stopping ? + * + * @var bool + */ + public bool $stopping = false; + + /** + * EventLoop class. + * + * @var ?string + */ + public ?string $eventLoop = null; + + /** + * Daemonize. + * + * @var bool + */ + public static bool $daemonize = false; + + /** + * Standard output stream + * + * @var resource + */ + public static $outputStream; + + /** + * Stdout file. + * + * @var string + */ + public static string $stdoutFile = '/dev/null'; + + /** + * The file to store master process PID. + * + * @var string + */ + public static string $pidFile = ''; + + /** + * The file used to store the master process status. + * + * @var string + */ + public static string $statusFile = ''; + + /** + * Log file. + * + * @var string + */ + public static string $logFile = ''; + + /** + * Log file maximum size in bytes, default 10M. + * + * @var int + */ + public static int $logFileMaxSize = 10_485_760; + + /** + * Global event loop. + * + * @var ?EventInterface + */ + public static ?EventInterface $globalEvent = null; + + /** + * Emitted when the master process gets a reload signal. + * + * @var ?callable + */ + public static $onMasterReload = null; + + /** + * Emitted when the master process terminated. + * + * @var ?callable + */ + public static $onMasterStop = null; + + /** + * Emitted when worker processes exited. + * + * @var ?callable + */ + public static $onWorkerExit = null; + + /** + * EventLoopClass + * + * @var ?class-string + */ + public static ?string $eventLoopClass = null; + + /** + * After sending the stop command to the child process stopTimeout seconds, + * if the process is still living then forced to kill. + * + * @var int + */ + public static int $stopTimeout = 2; + + /** + * Command + * + * @var string + */ + public static string $command = ''; + + /** + * The PID of master process. + * + * @var int + */ + protected static int $masterPid = 0; + + /** + * Listening socket. + * + * @var ?resource + */ + protected $mainSocket = null; + + /** + * Socket name. The format is like this http://0.0.0.0:80 . + * + * @var string + */ + protected string $socketName = ''; + + /** + * Context of socket. + * + * @var resource + */ + protected $socketContext = null; + + /** + * @var stdClass + */ + protected stdClass $context; + + /** + * All worker instances. + * + * @var Worker[] + */ + protected static array $workers = []; + + /** + * All worker processes pid. + * The format is like this [worker_id=>[pid=>pid, pid=>pid, ..], ..] + * + * @var array + */ + protected static array $pidMap = []; + + /** + * All worker processes waiting for restart. + * The format is like this [pid=>pid, pid=>pid]. + * + * @var array + */ + protected static array $pidsToRestart = []; + + /** + * Mapping from PID to worker process ID. + * The format is like this [worker_id=>[0=>$pid, 1=>$pid, ..], ..]. + * + * @var array + */ + protected static array $idMap = []; + + /** + * Current status. + * + * @var int + */ + protected static int $status = self::STATUS_INITIAL; + + /** + * UI data. + * + * @var array|int[] + */ + protected static array $uiLengthData = []; + + /** + * The file to store status info of current worker process. + * + * @var string + */ + protected static string $statisticsFile = ''; + + /** + * The file to store status info of connections. + * + * @var string + */ + protected static string $connectionsFile = ''; + + /** + * Start file. + * + * @var string + */ + protected static string $startFile = ''; + + /** + * Processes for windows. + * + * @var array + */ + protected static array $processForWindows = []; + + /** + * Status info of current worker process. + * + * @var array + */ + protected static array $globalStatistics = [ + 'start_timestamp' => 0, + 'worker_exit_info' => [] + ]; + + /** + * PHP built-in protocols. + * + * @var array + */ + public const BUILD_IN_TRANSPORTS = [ + 'tcp' => 'tcp', + 'udp' => 'udp', + 'unix' => 'unix', + 'ssl' => 'tcp' + ]; + + /** + * PHP built-in error types. + * + * @var array + */ + public const ERROR_TYPE = [ + E_ERROR => 'E_ERROR', + E_WARNING => 'E_WARNING', + E_PARSE => 'E_PARSE', + E_NOTICE => 'E_NOTICE', + E_CORE_ERROR => 'E_CORE_ERROR', + E_CORE_WARNING => 'E_CORE_WARNING', + E_COMPILE_ERROR => 'E_COMPILE_ERROR', + E_COMPILE_WARNING => 'E_COMPILE_WARNING', + E_USER_ERROR => 'E_USER_ERROR', + E_USER_WARNING => 'E_USER_WARNING', + E_USER_NOTICE => 'E_USER_NOTICE', + E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', + E_DEPRECATED => 'E_DEPRECATED', + E_USER_DEPRECATED => 'E_USER_DEPRECATED' + ]; + + /** + * Graceful stop or not. + * + * @var bool + */ + protected static bool $gracefulStop = false; + + /** + * If $outputStream support decorated + * + * @var bool + */ + protected static bool $outputDecorated; + + /** + * Worker object's hash id(unique identifier). + * + * @var ?string + */ + protected ?string $workerId = null; + + /** + * Constructor. + * + * @param string|null $socketName + * @param array $socketContext + */ + public function __construct(?string $socketName = null, array $socketContext = []) + { + // Save all worker instances. + $this->workerId = spl_object_hash($this); + $this->context = new stdClass(); + static::$workers[$this->workerId] = $this; + static::$pidMap[$this->workerId] = []; + + // Context for socket. + if ($socketName) { + $this->socketName = $socketName; + $socketContext['socket']['backlog'] ??= static::DEFAULT_BACKLOG; + $this->socketContext = stream_context_create($socketContext); + } + + // Set an empty onMessage callback. + $this->onMessage = function () { + // Empty. + }; + + } + + /** + * Run all worker instances. + * + * @return void + */ + public static function runAll(): void + { + try { + static::checkSapiEnv(); + static::initStdOut(); + static::init(); + static::parseCommand(); + static::checkPortAvailable(); + static::lock(); + static::daemonize(); + static::initWorkers(); + static::installSignal(); + static::saveMasterPid(); + static::lock(LOCK_UN); + static::displayUI(); + static::forkWorkers(); + static::resetStd(); + static::monitorWorkers(); + } catch (Throwable $e) { + static::log($e); + } + } + + /** + * Check sapi. + * + * @return void + */ + protected static function checkSapiEnv(): void + { + // Only for cli and micro. + if (!in_array(PHP_SAPI, ['cli', 'micro'])) { + exit("Only run in command line mode" . PHP_EOL); + } + // Check pcntl and posix extension for unix. + if (DIRECTORY_SEPARATOR === '/') { + foreach (['pcntl', 'posix'] as $name) { + if (!extension_loaded($name)) { + exit("Please install $name extension" . PHP_EOL); + } + } + } + // Check disable functions. + $disabledFunctions = explode(',', ini_get('disable_functions')); + $disabledFunctions = array_map('trim', $disabledFunctions); + $functionsToCheck = [ + 'stream_socket_server', + 'stream_socket_accept', + 'stream_socket_client', + 'pcntl_signal_dispatch', + 'pcntl_signal', + 'pcntl_alarm', + 'pcntl_fork', + 'pcntl_wait', + 'posix_getuid', + 'posix_getpwuid', + 'posix_kill', + 'posix_setsid', + 'posix_getpid', + 'posix_getpwnam', + 'posix_getgrnam', + 'posix_getgid', + 'posix_setgid', + 'posix_initgroups', + 'posix_setuid', + 'posix_isatty', + 'proc_open', + 'proc_get_status', + 'proc_close', + 'shell_exec', + 'exec', + 'putenv', + 'getenv', + ]; + $disabled = array_intersect($functionsToCheck, $disabledFunctions); + if (!empty($disabled)) { + $iniFilePath = (string)php_ini_loaded_file(); + exit('Notice: '. implode(',', $disabled) . " are disabled by disable_functions. " . PHP_EOL + . "Please remove them from disable_functions in $iniFilePath" . PHP_EOL); + } + } + + /** + * Init stdout. + * + * @return void + */ + protected static function initStdOut(): void + { + $defaultStream = fn () => defined('STDOUT') ? STDOUT : (@fopen('php://stdout', 'w') ?: fopen('php://output', 'w')); + static::$outputStream ??= $defaultStream(); //@phpstan-ignore-line + if (!is_resource(self::$outputStream) || get_resource_type(self::$outputStream) !== 'stream') { + $type = get_debug_type(self::$outputStream); + static::$outputStream = $defaultStream(); + throw new RuntimeException(sprintf('The $outputStream must to be a stream, %s given', $type)); + } + + static::$outputDecorated ??= self::hasColorSupport(); + } + + /** + * Borrowed from the symfony console + * @link https://github.com/symfony/console/blob/0d14a9f6d04d4ac38a8cea1171f4554e325dae92/Output/StreamOutput.php#L92 + */ + private static function hasColorSupport(): bool + { + // Follow https://no-color.org/ + if (getenv('NO_COLOR') !== false) { + return false; + } + + if (getenv('TERM_PROGRAM') === 'Hyper') { + return true; + } + + if (DIRECTORY_SEPARATOR === '\\') { + return (function_exists('sapi_windows_vt100_support') && @sapi_windows_vt100_support(self::$outputStream)) + || getenv('ANSICON') !== false + || getenv('ConEmuANSI') === 'ON' + || getenv('TERM') === 'xterm'; + } + + return stream_isatty(self::$outputStream); + } + + /** + * Init. + * + * @return void + */ + protected static function init(): void + { + set_error_handler(static function (int $code, string $msg, string $file, int $line): bool { + static::safeEcho(sprintf("%s \"%s\" in file %s on line %d\n", static::getErrorType($code), $msg, $file, $line)); + return true; + }); + + // $_SERVER. + $_SERVER['SERVER_SOFTWARE'] = 'Workerman/' . static::VERSION; + $_SERVER['SERVER_START_TIME'] = time(); + + // Start file. + $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + static::$startFile = static::$startFile ?: end($backtrace)['file']; + $startFilePrefix = basename(static::$startFile); + $startFileDir = dirname(static::$startFile); + + // Compatible with older workerman versions for pid file. + if (empty(static::$pidFile)) { + $unique_prefix = \str_replace('/', '_', static::$startFile); + $file = __DIR__ . "/../../$unique_prefix.pid"; + if (is_file($file)) { + static::$pidFile = $file; + } + } + + // Pid file. + static::$pidFile = static::$pidFile ?: sprintf('%s/workerman.%s.pid', $startFileDir, $startFilePrefix); + + // Status file. + static::$statusFile = static::$statusFile ?: sprintf('%s/workerman.%s.status', $startFileDir, $startFilePrefix); + static::$statisticsFile = static::$statisticsFile ?: static::$statusFile; + static::$connectionsFile = static::$connectionsFile ?: static::$statusFile . '.connection'; + + // Log file. + static::$logFile = static::$logFile ?: sprintf('%s/workerman.log', $startFileDir); + + if (static::$logFile !== '/dev/null' && !is_file(static::$logFile) && !str_contains(static::$logFile, '://')) { + // if /runtime/logs default folder not exists + if (!is_dir(dirname(static::$logFile))) { + mkdir(dirname(static::$logFile), 0777, true); + } + touch(static::$logFile); + chmod(static::$logFile, 0644); + } + + // State. + static::$status = static::STATUS_STARTING; + + // Init global event. + static::initGlobalEvent(); + + // For statistics. + static::$globalStatistics['start_timestamp'] = time(); + + // Process title. + static::setProcessTitle('WorkerMan: master process start_file=' . static::$startFile); + + // Init data for worker id. + static::initId(); + + // Timer init. + Timer::init(); + + restore_error_handler(); + } + + /** + * Init global event. + * + * @return void + */ + protected static function initGlobalEvent(): void + { + if (static::$globalEvent !== null) { + static::$eventLoopClass = get_class(static::$globalEvent); + static::$globalEvent = null; + return; + } + + if (!empty(static::$eventLoopClass)) { + if (!is_subclass_of(static::$eventLoopClass, EventInterface::class)) { + throw new RuntimeException(sprintf('%s::$eventLoopClass must implement %s', static::class, EventInterface::class)); + } + return; + } + + static::$eventLoopClass = match (true) { + extension_loaded('event') => Event::class, + default => Select::class, + }; + } + + /** + * Lock. + * + * @param int $flag + * @return void + */ + protected static function lock(int $flag = LOCK_EX): void + { + static $fd; + if (DIRECTORY_SEPARATOR !== '/') { + return; + } + $lockFile = static::$pidFile . '.lock'; + $fd = $fd ?: fopen($lockFile, 'a+'); + if ($fd) { + flock($fd, $flag); + if ($flag === LOCK_UN) { + fclose($fd); + $fd = null; + clearstatcache(); + if (is_file($lockFile)) { + unlink($lockFile); + } + } + } + } + + /** + * Init All worker instances. + * + * @return void + */ + protected static function initWorkers(): void + { + if (DIRECTORY_SEPARATOR !== '/') { + return; + } + + foreach (static::$workers as $worker) { + // Worker name. + if (empty($worker->name)) { + $worker->name = 'none'; + } + + // Get unix user of the worker process. + if (empty($worker->user)) { + $worker->user = static::getCurrentUser(); + } else { + if (posix_getuid() !== 0 && $worker->user !== static::getCurrentUser()) { + static::log('Warning: You must have the root privileges to change uid and gid.'); + } + } + + // Socket name. + $worker->context->statusSocket = $worker->getSocketName(); + + // Event-loop name. + $eventLoopName = $worker->eventLoop ?: static::$eventLoopClass; + $worker->context->eventLoopName = strtolower(substr($eventLoopName, strrpos($eventLoopName, '\\') + 1)); + + // Status name. + $worker->context->statusState = ' [OK] '; + + // Get column mapping for UI + foreach (static::getUiColumns() as $columnName => $prop) { + !isset($worker->$prop) && !isset($worker->context->$prop) && $worker->context->$prop = 'NNNN'; + $propLength = strlen((string)($worker->$prop ?? $worker->context->$prop)); + $key = 'max' . ucfirst(strtolower($columnName)) . 'NameLength'; + static::$uiLengthData[$key] = max(static::$uiLengthData[$key] ?? 2 * static::UI_SAFE_LENGTH, $propLength); + } + + // Listen. + if (!$worker->reusePort) { + $worker->listen(false); + } + } + } + + /** + * Get all worker instances. + * + * @return Worker[] + */ + public static function getAllWorkers(): array + { + return static::$workers; + } + + /** + * Get global event-loop instance. + * + * @return EventInterface + */ + public static function getEventLoop(): EventInterface + { + return static::$globalEvent; + } + + /** + * Get main socket resource + * + * @return resource + */ + public function getMainSocket(): mixed + { + return $this->mainSocket; + } + + /** + * Init idMap. + * + * @return void + */ + protected static function initId(): void + { + foreach (static::$workers as $workerId => $worker) { + $newIdMap = []; + $worker->count = max($worker->count, 1); + for ($key = 0; $key < $worker->count; $key++) { + $newIdMap[$key] = static::$idMap[$workerId][$key] ?? 0; + } + static::$idMap[$workerId] = $newIdMap; + } + } + + /** + * Get unix user of current process. + * + * @return string + */ + protected static function getCurrentUser(): string + { + $userInfo = posix_getpwuid(posix_getuid()); + return $userInfo['name'] ?? 'unknown'; + } + + /** + * Display staring UI. + * + * @return void + */ + protected static function displayUI(): void + { + $tmpArgv = static::getArgv(); + if (in_array('-q', $tmpArgv)) { + return; + } + + + $lineVersion = static::getVersionLine(); + // For windows + if (DIRECTORY_SEPARATOR !== '/') { + static::safeEcho("---------------------------------------------- WORKERMAN -----------------------------------------------\r\n"); + static::safeEcho($lineVersion); + static::safeEcho("----------------------------------------------- WORKERS ------------------------------------------------\r\n"); + static::safeEcho("worker listen processes status\r\n"); + return; + } + + // For unix + !defined('LINE_VERSION_LENGTH') && define('LINE_VERSION_LENGTH', strlen($lineVersion)); + $totalLength = static::getSingleLineTotalLength(); + $lineOne = '' . str_pad(' WORKERMAN ', $totalLength + strlen(''), '-', STR_PAD_BOTH) . '' . PHP_EOL; + $lineTwo = str_pad(' WORKERS ', $totalLength + strlen(''), '-', STR_PAD_BOTH) . PHP_EOL; + static::safeEcho($lineOne . $lineVersion . $lineTwo); + + //Show title + $title = ''; + foreach (static::getUiColumns() as $columnName => $prop) { + $key = 'max' . ucfirst(strtolower($columnName)) . 'NameLength'; + //just keep compatible with listen name + $columnName === 'socket' && $columnName = 'listen'; + $title .= "$columnName" . str_pad('', static::getUiColumnLength($key) + static::UI_SAFE_LENGTH - strlen($columnName)); + } + $title && static::safeEcho($title . PHP_EOL); + + //Show content + foreach (static::$workers as $worker) { + $content = ''; + foreach (static::getUiColumns() as $columnName => $prop) { + $propValue = (string)($worker->$prop ?? $worker->context->$prop); + $key = 'max' . ucfirst(strtolower($columnName)) . 'NameLength'; + preg_match_all("/(|<\/n>||<\/w>||<\/g>)/i", $propValue, $matches); + $placeHolderLength = !empty($matches[0]) ? strlen(implode('', $matches[0])) : 0; + $content .= str_pad($propValue, static::getUiColumnLength($key) + static::UI_SAFE_LENGTH + $placeHolderLength); + } + $content && static::safeEcho($content . PHP_EOL); + } + + //Show last line + $lineLast = str_pad('', static::getSingleLineTotalLength(), '-') . PHP_EOL; + !empty($content) && static::safeEcho($lineLast); + + if (static::$daemonize) { + static::safeEcho('Input "php ' . basename(static::$startFile) . ' stop" to stop. Start success.' . "\n\n"); + } else if (!empty(static::$command)) { + static::safeEcho("Start success.\n"); // Workerman used as library + } else { + static::safeEcho("Press Ctrl+C to stop. Start success.\n"); + } + } + + /** + * @return string + */ + protected static function getVersionLine(): string + { + //Show version + $jitStatus = function_exists('opcache_get_status') && (opcache_get_status()['jit']['on'] ?? false) === true ? 'on' : 'off'; + $version = str_pad('Workerman/' . static::VERSION, 24); + $version .= str_pad('PHP/' . PHP_VERSION . ' (JIT ' . $jitStatus . ')', 30); + $version .= php_uname('s') . '/' . php_uname('r') . PHP_EOL; + return $version; + } + + /** + * Get UI columns to be shown in terminal + * + * 1. $columnMap: ['ui_column_name' => 'clas_property_name'] + * 2. Consider move into configuration in future + * + * @return array + */ + public static function getUiColumns(): array + { + return [ + 'event-loop' => 'eventLoopName', + 'proto' => 'transport', + 'user' => 'user', + 'worker' => 'name', + 'socket' => 'statusSocket', + 'count' => 'count', + 'state' => 'statusState', + ]; + } + + /** + * Get single line total length for ui + * + * @return int + */ + public static function getSingleLineTotalLength(): int + { + $totalLength = 0; + + foreach (static::getUiColumns() as $columnName => $prop) { + $key = 'max' . ucfirst(strtolower($columnName)) . 'NameLength'; + $totalLength += static::getUiColumnLength($key) + static::UI_SAFE_LENGTH; + } + + //Keep beauty when show less columns + !defined('LINE_VERSION_LENGTH') && define('LINE_VERSION_LENGTH', 0); + $totalLength <= LINE_VERSION_LENGTH && $totalLength = LINE_VERSION_LENGTH; + + return $totalLength; + } + + /** + * Parse command. + * + * @return void + */ + protected static function parseCommand(): void + { + if (DIRECTORY_SEPARATOR !== '/') { + return; + } + + // Check argv; + $startFile = basename(static::$startFile); + $usage = "Usage: php yourfile [mode]\nCommands: \nstart\t\tStart worker in DEBUG mode.\n\t\tUse mode -d to start in DAEMON mode.\nstop\t\tStop worker.\n\t\tUse mode -g to stop gracefully.\nrestart\t\tRestart workers.\n\t\tUse mode -d to start in DAEMON mode.\n\t\tUse mode -g to stop gracefully.\nreload\t\tReload codes.\n\t\tUse mode -g to reload gracefully.\nstatus\t\tGet worker status.\n\t\tUse mode -d to show live status.\nconnections\tGet worker connections.\n"; + $availableCommands = [ + 'start', + 'stop', + 'restart', + 'reload', + 'status', + 'connections', + ]; + $availableMode = [ + '-d', + '-g' + ]; + $command = $mode = ''; + foreach (static::getArgv() as $value) { + if (!$command && in_array($value, $availableCommands)) { + $command = $value; + } + if (!$mode && in_array($value, $availableMode)) { + $mode = $value; + } + } + + if (!$command) { + exit($usage); + } + + // Start command. + $modeStr = ''; + if ($command === 'start') { + if ($mode === '-d' || static::$daemonize) { + $modeStr = 'in DAEMON mode'; + } else { + $modeStr = 'in DEBUG mode'; + } + } + static::log("Workerman[$startFile] $command $modeStr"); + + // Get master process PID. + $masterPid = is_file(static::$pidFile) ? (int)file_get_contents(static::$pidFile) : 0; + // Master is still alive? + if (static::checkMasterIsAlive($masterPid)) { + if ($command === 'start') { + static::log("Workerman[$startFile] already running"); + exit; + } + } elseif ($command !== 'start' && $command !== 'restart') { + static::log("Workerman[$startFile] not run"); + exit; + } + + // execute command. + switch ($command) { + case 'start': + if ($mode === '-d') { + static::$daemonize = true; + } + break; + case 'status': + // Delete status file on shutdown + register_shutdown_function(unlink(...), static::$statisticsFile); + while (1) { + // Master process will send SIGIOT signal to all child processes. + posix_kill($masterPid, SIGIOT); + // Waiting a moment. + sleep(1); + // Clear terminal. + if ($mode === '-d') { + static::safeEcho("\33[H\33[2J\33(B\33[m", true); + } + // Echo status data. + static::safeEcho(static::formatProcessStatusData()); + if ($mode !== '-d') { + exit(0); + } + static::safeEcho("\nPress Ctrl+C to quit.\n\n"); + } + case 'connections': + // Delete status file on shutdown + register_shutdown_function(unlink(...), static::$connectionsFile); + // Master process will send SIGIO signal to all child processes. + posix_kill($masterPid, SIGIO); + // Waiting a moment. + usleep(500000); + // Display statistics data from a disk file. + static::safeEcho(static::formatConnectionStatusData()); + exit(0); + case 'restart': + case 'stop': + if ($mode === '-g') { + static::$gracefulStop = true; + $sig = SIGQUIT; + static::log("Workerman[$startFile] is gracefully stopping ..."); + } else { + static::$gracefulStop = false; + $sig = SIGINT; + static::log("Workerman[$startFile] is stopping ..."); + } + // Send stop signal to master process. + $masterPid && posix_kill($masterPid, $sig); + // Timeout. + $timeout = static::$stopTimeout + 3; + $startTime = time(); + // Check master process is still alive? + while (1) { + $masterIsAlive = $masterPid && posix_kill($masterPid, 0); + if ($masterIsAlive) { + // Timeout? + if (!static::getGracefulStop() && time() - $startTime >= $timeout) { + static::log("Workerman[$startFile] stop fail"); + exit; + } + // Waiting a moment. + usleep(10000); + continue; + } + // Stop success. + static::log("Workerman[$startFile] stop success"); + if ($command === 'stop') { + exit(0); + } + if ($mode === '-d') { + static::$daemonize = true; + } + break; + } + break; + case 'reload': + if ($mode === '-g') { + $sig = SIGUSR2; + } else { + $sig = SIGUSR1; + } + posix_kill($masterPid, $sig); + exit; + default : + static::safeEcho('Unknown command: ' . $command . "\n"); + exit($usage); + } + } + + /** + * Get argv. + * + * @return array + */ + public static function getArgv(): array + { + global $argv; + return static::$command ? [...$argv, ...explode(' ', static::$command)] : $argv; + } + + /** + * Format status data. + * + * @return string + */ + protected static function formatProcessStatusData(): string + { + static $totalRequestCache = []; + if (!is_readable(static::$statisticsFile)) { + return ''; + } + $info = file(static::$statisticsFile, FILE_IGNORE_NEW_LINES); + if (!$info) { + return ''; + } + $statusStr = ''; + $currentTotalRequest = []; + $workerInfo = []; + try { + $workerInfo = unserialize($info[0], ['allowed_classes' => false]); + } catch (Throwable) { + // do nothing + } + if (!is_array($workerInfo)) { + $workerInfo = []; + } + ksort($workerInfo, SORT_NUMERIC); + unset($info[0]); + $dataWaitingSort = []; + $readProcessStatus = false; + $totalRequests = 0; + $totalQps = 0; + $totalConnections = 0; + $totalFails = 0; + $totalMemory = 0; + $totalTimers = 0; + $maxLen1 = max(static::getUiColumnLength('maxSocketNameLength'), 2 * static::UI_SAFE_LENGTH); + $maxLen2 = max(static::getUiColumnLength('maxWorkerNameLength'), 2 * static::UI_SAFE_LENGTH); + foreach ($info as $value) { + if (!$readProcessStatus) { + $statusStr .= $value . "\n"; + if (preg_match('/^pid.*?memory.*?listening/', $value)) { + $readProcessStatus = true; + } + continue; + } + if (preg_match('/^[0-9]+/', $value, $pidMath)) { + $pid = $pidMath[0]; + $dataWaitingSort[$pid] = $value; + if (preg_match('/^\S+?\s+?(\S+?)\s+?(\S+?)\s+?(\S+?)\s+?(\S+?)\s+?(\S+?)\s+?(\S+?)\s+?(\S+?)\s+?/', $value, $match)) { + $totalMemory += (float)str_ireplace('M', '', $match[1]); + $maxLen1 = max($maxLen1, strlen($match[2])); + $maxLen2 = max($maxLen2, strlen($match[3])); + $totalConnections += (int)$match[4]; + $totalFails += (int)$match[5]; + $totalTimers += (int)$match[6]; + $currentTotalRequest[$pid] = $match[7]; + $totalRequests += (int)$match[7]; + } + } + } + foreach ($workerInfo as $pid => $info) { + if (!isset($dataWaitingSort[$pid])) { + $statusStr .= "$pid\t" . str_pad('N/A', 7) . " " + . str_pad($info['listen'], $maxLen1) . " " + . str_pad((string)$info['name'], $maxLen2) . " " + . str_pad('N/A', 11) . " " . str_pad('N/A', 9) . " " + . str_pad('N/A', 7) . " " . str_pad('N/A', 13) . " N/A [busy] \n"; + continue; + } + //$qps = isset($totalRequestCache[$pid]) ? $currentTotalRequest[$pid] + if (!isset($totalRequestCache[$pid], $currentTotalRequest[$pid])) { + $qps = 0; + } else { + $qps = $currentTotalRequest[$pid] - $totalRequestCache[$pid]; + $totalQps += $qps; + } + $statusStr .= $dataWaitingSort[$pid] . " " . str_pad((string)$qps, 6) . " [idle]\n"; + } + $totalRequestCache = $currentTotalRequest; + $statusStr .= "---------------------------------------------------PROCESS STATUS--------------------------------------------------------\n"; + $statusStr .= "Summary\t" . str_pad($totalMemory . 'M', 7) . " " + . str_pad('-', $maxLen1) . " " + . str_pad('-', $maxLen2) . " " + . str_pad((string)$totalConnections, 11) . " " . str_pad((string)$totalFails, 9) . " " + . str_pad((string)$totalTimers, 7) . " " . str_pad((string)$totalRequests, 13) . " " + . str_pad((string)$totalQps, 6) . " [Summary] \n"; + return $statusStr; + } + + protected static function formatConnectionStatusData(): string + { + return file_get_contents(static::$connectionsFile); + } + + /** + * Install signal handler. + * + * @return void + */ + protected static function installSignal(): void + { + if (DIRECTORY_SEPARATOR !== '/') { + return; + } + $signals = [SIGINT, SIGTERM, SIGHUP, SIGTSTP, SIGQUIT, SIGUSR1, SIGUSR2, SIGIOT, SIGIO]; + foreach ($signals as $signal) { + pcntl_signal($signal, static::signalHandler(...), false); + } + // ignore + pcntl_signal(SIGPIPE, SIG_IGN, false); + } + + /** + * Reinstall signal handler. + * + * @return void + */ + protected static function reinstallSignal(): void + { + if (DIRECTORY_SEPARATOR !== '/') { + return; + } + $signals = [SIGINT, SIGTERM, SIGHUP, SIGTSTP, SIGQUIT, SIGUSR1, SIGUSR2, SIGIOT, SIGIO]; + foreach ($signals as $signal) { + // Rewrite master process signal. + static::$globalEvent->onSignal($signal, static::signalHandler(...)); + } + } + + /** + * Signal handler. + * + * @param int $signal + */ + protected static function signalHandler(int $signal): void + { + switch ($signal) { + // Stop. + case SIGINT: + case SIGTERM: + case SIGHUP: + case SIGTSTP: + static::$gracefulStop = false; + static::stopAll(0, 'received signal ' . static::getSignalName($signal)); + break; + // Graceful stop. + case SIGQUIT: + static::$gracefulStop = true; + static::stopAll(0, 'received signal ' . static::getSignalName($signal)); + break; + // Reload. + case SIGUSR2: + case SIGUSR1: + if (static::$status === static::STATUS_RELOADING || static::$status === static::STATUS_SHUTDOWN) { + return; + } + static::$gracefulStop = $signal === SIGUSR2; + static::$pidsToRestart = static::getAllWorkerPids(); + static::reload(); + break; + // Show status. + case SIGIOT: + static::writeStatisticsToStatusFile(); + break; + // Show connection status. + case SIGIO: + static::writeConnectionsStatisticsToStatusFile(); + break; + } + } + + /** + * Get signal name. + * + * @param int $signal + * @return string + */ + protected static function getSignalName(int $signal): string + { + return match ($signal) { + SIGINT => 'SIGINT', + SIGTERM => 'SIGTERM', + SIGHUP => 'SIGHUP', + SIGTSTP => 'SIGTSTP', + SIGQUIT => 'SIGQUIT', + SIGUSR1 => 'SIGUSR1', + SIGUSR2 => 'SIGUSR2', + SIGIOT => 'SIGIOT', + SIGIO => 'SIGIO', + default => $signal, + }; + } + + /** + * Run as daemon mode. + */ + protected static function daemonize(): void + { + if (!static::$daemonize || DIRECTORY_SEPARATOR !== '/') { + return; + } + umask(0); + $pid = pcntl_fork(); + if (-1 === $pid) { + throw new RuntimeException('Fork fail'); + } elseif ($pid > 0) { + exit(0); + } + if (-1 === posix_setsid()) { + throw new RuntimeException("Setsid fail"); + } + // Fork again avoid SVR4 system regain the control of terminal. + $pid = pcntl_fork(); + if (-1 === $pid) { + throw new RuntimeException("Fork fail"); + } elseif (0 !== $pid) { + exit(0); + } + } + + /** + * Redirect standard output to stdoutFile. + * + * @return void + */ + public static function resetStd(): void + { + if (!static::$daemonize || DIRECTORY_SEPARATOR !== '/') { + return; + } + + if (is_resource(STDOUT)) { + fclose(STDOUT); + } + + if (is_resource(STDERR)) { + fclose(STDERR); + } + + if (is_resource(static::$outputStream)) { + fclose(static::$outputStream); + } + + set_error_handler(static fn (): bool => true); + $stdOutStream = fopen(static::$stdoutFile, 'a'); + restore_error_handler(); + + if ($stdOutStream === false) { + return; + } + + static::$outputStream = $stdOutStream; + + // Fix standard output cannot redirect of PHP 8.1.8's bug + if (function_exists('posix_isatty') && posix_isatty(2)) { + ob_start(function (string $string) { + file_put_contents(static::$stdoutFile, $string, FILE_APPEND); + }, 1); + } + } + + /** + * Save pid. + */ + protected static function saveMasterPid(): void + { + if (DIRECTORY_SEPARATOR !== '/') { + return; + } + + static::$masterPid = posix_getpid(); + if (false === file_put_contents(static::$pidFile, static::$masterPid)) { + throw new RuntimeException('can not save pid to ' . static::$pidFile); + } + } + + /** + * Get all pids of worker processes. + * + * @return array + */ + protected static function getAllWorkerPids(): array + { + $pidArray = []; + foreach (static::$pidMap as $workerPidArray) { + foreach ($workerPidArray as $workerPid) { + $pidArray[$workerPid] = $workerPid; + } + } + return $pidArray; + } + + /** + * Fork some worker processes. + * + * @return void + */ + protected static function forkWorkers(): void + { + if (DIRECTORY_SEPARATOR === '/') { + static::forkWorkersForLinux(); + } else { + static::forkWorkersForWindows(); + } + } + + /** + * Fork some worker processes. + * + * @return void + */ + protected static function forkWorkersForLinux(): void + { + foreach (static::$workers as $worker) { + if (static::$status === static::STATUS_STARTING) { + if (empty($worker->name)) { + $worker->name = $worker->getSocketName(); + } + } + while (count(static::$pidMap[$worker->workerId]) < $worker->count) { + static::forkOneWorkerForLinux($worker); + } + } + } + + /** + * Fork some worker processes. + * + * @return void + */ + protected static function forkWorkersForWindows(): void + { + $files = static::getStartFilesForWindows(); + if (count($files) === 1 || in_array('-q', static::getArgv())) { + if (count(static::$workers) > 1) { + static::safeEcho("@@@ Error: multi workers init in one php file are not support @@@\r\n"); + static::safeEcho("@@@ See https://www.workerman.net/doc/workerman/faq/multi-woker-for-windows.html @@@\r\n"); + } elseif (count(static::$workers) <= 0) { + exit("@@@no worker inited@@@\r\n\r\n"); + } + + reset(static::$workers); + /** @var Worker $worker */ + $worker = current(static::$workers); + + Timer::delAll(); + + //Update process state. + static::$status = static::STATUS_RUNNING; + + // Register shutdown function for checking errors. + register_shutdown_function(static::checkErrors(...)); + + // Create a global event loop. + if (static::$globalEvent === null) { + static::$eventLoopClass = $worker->eventLoop ?: static::$eventLoopClass; + static::$globalEvent = new static::$eventLoopClass(); + static::$globalEvent->setErrorHandler(function ($exception) { + static::stopAll(250, $exception); + }); + } + + // Reinstall signal. + static::reinstallSignal(); + + // Init Timer. + Timer::init(static::$globalEvent); + + restore_error_handler(); + + // Add an empty timer to prevent the event-loop from exiting. + Timer::add(0.8, function (){}); + + // Compatibility with the bug in Swow where the first request on Windows fails to trigger stream_select. + if (extension_loaded('swow')) { + Timer::delay(0.1 , function(){ + $stream = tmpfile(); + static::$globalEvent->onReadable($stream, function($stream) { + static::$globalEvent->offReadable($stream); + }); + }); + } + + // Display UI. + static::safeEcho(str_pad($worker->name, 48) . str_pad($worker->getSocketName(), 36) . str_pad('1', 10) . " [ok]\n"); + $worker->run(); + static::$globalEvent->run(); + if (static::$status !== self::STATUS_SHUTDOWN) { + $err = new RuntimeException('event-loop exited'); + static::log($err); + exit(250); + } + exit(0); + } + + static::$globalEvent = new Select(); + static::$globalEvent->setErrorHandler(function ($exception) { + static::stopAll(250, $exception); + }); + Timer::init(static::$globalEvent); + foreach ($files as $startFile) { + static::forkOneWorkerForWindows($startFile); + } + } + + /** + * Get start files for windows. + * + * @return array + */ + public static function getStartFilesForWindows(): array + { + $files = []; + foreach (static::getArgv() as $file) { + if (is_file($file)) { + $files[$file] = $file; + } + } + return $files; + } + + /** + * Fork one worker process. + * + * @param string $startFile + */ + public static function forkOneWorkerForWindows(string $startFile): void + { + $startFile = realpath($startFile); + $descriptorSpec = [STDIN, STDOUT, STDOUT]; + $pipes = []; + $process = proc_open('"' . PHP_BINARY . '" ' . " \"$startFile\" -q", $descriptorSpec, $pipes, null, null, ['bypass_shell' => true]); + + if (static::$globalEvent === null) { + static::$globalEvent = new Select(); + static::$globalEvent->setErrorHandler(function ($exception) { + static::stopAll(250, $exception); + }); + Timer::init(static::$globalEvent); + } + + // 保存子进程句柄 + static::$processForWindows[$startFile] = [$process, $startFile]; + } + + /** + * check worker status for windows. + * + * @return void + */ + protected static function checkWorkerStatusForWindows(): void + { + foreach (static::$processForWindows as $processData) { + $process = $processData[0]; + $startFile = $processData[1]; + $status = proc_get_status($process); + if (!$status['running']) { + static::safeEcho("process $startFile terminated and try to restart\n"); + proc_close($process); + static::forkOneWorkerForWindows($startFile); + } + } + } + + /** + * Fork one worker process. + * + * @param self $worker + */ + protected static function forkOneWorkerForLinux(self $worker): void + { + // Get available worker id. + $id = static::getId($worker->workerId, 0); + $pid = pcntl_fork(); + // For master process. + if ($pid > 0) { + static::$pidMap[$worker->workerId][$pid] = $pid; + static::$idMap[$worker->workerId][$id] = $pid; + } // For child processes. + elseif (0 === $pid) { + srand(); + mt_srand(); + static::$gracefulStop = false; + if (static::$status === static::STATUS_STARTING) { + static::resetStd(); + } + static::$pidsToRestart = static::$pidMap = []; + // Remove other listener. + foreach (static::$workers as $key => $oneWorker) { + if ($oneWorker->workerId !== $worker->workerId) { + $oneWorker->unlisten(); + unset(static::$workers[$key]); + } + } + Timer::delAll(); + + //Update process state. + static::$status = static::STATUS_RUNNING; + + // Register shutdown function for checking errors. + register_shutdown_function(static::checkErrors(...)); + + // Create a global event loop. + if (static::$globalEvent === null) { + static::$eventLoopClass = $worker->eventLoop ?: static::$eventLoopClass; + static::$globalEvent = new static::$eventLoopClass(); + static::$globalEvent->setErrorHandler(function ($exception) { + static::stopAll(250, $exception); + }); + } + + // Reinstall signal. + static::reinstallSignal(); + + // Init Timer. + Timer::init(static::$globalEvent); + + restore_error_handler(); + + static::setProcessTitle('WorkerMan: worker process ' . $worker->name . ' ' . $worker->getSocketName()); + $worker->setUserAndGroup(); + $worker->id = $id; + $worker->run(); + // Main loop. + static::$globalEvent->run(); + if (static::$status !== self::STATUS_SHUTDOWN) { + $err = new Exception('event-loop exited'); + static::log($err); + exit(250); + } + exit(0); + } else { + throw new RuntimeException("forkOneWorker fail"); + } + } + + /** + * Get worker id. + * + * @param string $workerId + * @param int $pid + * @return false|int|string + */ + protected static function getId(string $workerId, int $pid): false|int|string + { + return array_search($pid, static::$idMap[$workerId]); + } + + /** + * Set unix user and group for current process. + * + * @return void + */ + public function setUserAndGroup(): void + { + // Get uid. + $userInfo = posix_getpwnam($this->user); + if (!$userInfo) { + static::log("Warning: User $this->user not exists"); + return; + } + $uid = $userInfo['uid']; + // Get gid. + if ($this->group) { + $groupInfo = posix_getgrnam($this->group); + if (!$groupInfo) { + static::log("Warning: Group $this->group not exists"); + return; + } + $gid = $groupInfo['gid']; + } else { + $gid = $userInfo['gid']; + } + + // Set uid and gid. + if ($uid !== posix_getuid() || $gid !== posix_getgid()) { + if (!posix_setgid($gid) || !posix_initgroups($userInfo['name'], $gid) || !posix_setuid($uid)) { + static::log("Warning: change gid or uid fail."); + } + } + } + + /** + * Set process name. + * + * @param string $title + * @return void + */ + protected static function setProcessTitle(string $title): void + { + set_error_handler(static fn (): bool => true); + cli_set_process_title($title); + restore_error_handler(); + } + + /** + * Monitor all child processes. + * + * @return void + * @throws Throwable + */ + protected static function monitorWorkers(): void + { + if (DIRECTORY_SEPARATOR === '/') { + static::monitorWorkersForLinux(); + } else { + static::monitorWorkersForWindows(); + } + } + + /** + * Monitor all child processes. + * + * @return void + */ + protected static function monitorWorkersForLinux(): void + { + static::$status = static::STATUS_RUNNING; + // @phpstan-ignore-next-line While loop condition is always true. + while (1) { + // Calls signal handlers for pending signals. + pcntl_signal_dispatch(); + // Suspends execution of the current process until a child has exited, or until a signal is delivered + $status = 0; + $pid = pcntl_wait($status, WUNTRACED); + // Calls signal handlers for pending signals again. + pcntl_signal_dispatch(); + // If a child has already exited. + if ($pid > 0) { + // Find out which worker process exited. + foreach (static::$pidMap as $workerId => $workerPidArray) { + if (isset($workerPidArray[$pid])) { + $worker = static::$workers[$workerId]; + // Fix exit with status 2 for php8.2 + if ($status === SIGINT && static::$status === static::STATUS_SHUTDOWN) { + $status = 0; + } + // Exit status. + if ($status !== 0) { + static::log("worker[$worker->name:$pid] exit with status $status"); + } + + // onWorkerExit + if (static::$onWorkerExit) { + try { + (static::$onWorkerExit)($worker, $status, $pid); + } catch (Throwable $exception) { + static::log("worker[$worker->name] onWorkerExit $exception"); + } + } + + // For Statistics. + static::$globalStatistics['worker_exit_info'][$workerId][$status] ??= 0; + static::$globalStatistics['worker_exit_info'][$workerId][$status]++; + + // Clear process data. + unset(static::$pidMap[$workerId][$pid]); + + // Mark id is available. + $id = static::getId($workerId, $pid); + if ($id !== false) { + static::$idMap[$workerId][$id] = 0; + } + + break; + } + } + // Is still running state then fork a new worker process. + if (static::$status !== static::STATUS_SHUTDOWN) { + static::forkWorkers(); + // If reloading continue. + if (isset(static::$pidsToRestart[$pid])) { + unset(static::$pidsToRestart[$pid]); + static::reload(); + } + } + } + + // If shutdown state and all child processes exited, then master process exit. + if (static::$status === static::STATUS_SHUTDOWN && empty(static::getAllWorkerPids())) { + static::exitAndClearAll(); + } + } + } + + /** + * Monitor all child processes. + * + * @return void + */ + protected static function monitorWorkersForWindows(): void + { + Timer::add(1, static::checkWorkerStatusForWindows(...)); + + static::$globalEvent->run(); + } + + /** + * Exit current process. + */ + protected static function exitAndClearAll(): void + { + clearstatcache(); + foreach (static::$workers as $worker) { + $socketName = $worker->getSocketName(); + if ($worker->transport === 'unix' && $socketName) { + [, $address] = explode(':', $socketName, 2); + $address = substr($address, strpos($address, '/') + 2); + if (file_exists($address)) { + @unlink($address); + } + } + } + if (file_exists(static::$pidFile)) { + @unlink(static::$pidFile); + } + static::log("Workerman[" . basename(static::$startFile) . "] has been stopped"); + if (static::$onMasterStop) { + (static::$onMasterStop)(); + } + exit(0); + } + + /** + * Execute reload. + * + * @return void + */ + protected static function reload(): void + { + // For master process. + if (static::$masterPid === posix_getpid()) { + $sig = static::getGracefulStop() ? SIGUSR2 : SIGUSR1; + // Set reloading state. + if (static::$status === static::STATUS_RUNNING) { + static::log("Workerman[" . basename(static::$startFile) . "] reloading"); + static::$status = static::STATUS_RELOADING; + + static::resetStd(); + // Try to emit onMasterReload callback. + if (static::$onMasterReload) { + try { + (static::$onMasterReload)(); + } catch (Throwable $e) { + static::stopAll(250, $e); + } + static::initId(); + } + + // Send reload signal to all child processes. + $reloadablePidArray = []; + foreach (static::$pidMap as $workerId => $workerPidArray) { + $worker = static::$workers[$workerId]; + if ($worker->reloadable) { + $reloadablePidArray += $workerPidArray; + continue; + } + // Send reload signal to a worker process which reloadable is false. + array_walk($workerPidArray, static fn ($pid) => posix_kill($pid, $sig)); + } + // Get all pids that are waiting reload. + static::$pidsToRestart = array_intersect(static::$pidsToRestart, $reloadablePidArray); + } + + // Reload complete. + if (empty(static::$pidsToRestart)) { + if (static::$status !== static::STATUS_SHUTDOWN) { + static::$status = static::STATUS_RUNNING; + } + return; + } + // Continue reload. + $oneWorkerPid = current(static::$pidsToRestart); + // Send reload signal to a worker process. + posix_kill($oneWorkerPid, $sig); + // If the process does not exit after stopTimeout seconds try to kill it. + if (!static::getGracefulStop()) { + Timer::add(static::$stopTimeout, posix_kill(...), [$oneWorkerPid, SIGKILL], false); + } + } // For child processes. + else { + reset(static::$workers); + $worker = current(static::$workers); + // Try to emit onWorkerReload callback. + if ($worker->onWorkerReload) { + try { + ($worker->onWorkerReload)($worker); + } catch (Throwable $e) { + static::stopAll(250, $e); + } + } + + if ($worker->reloadable) { + static::stopAll(); + } else { + static::resetStd(); + } + } + } + + /** + * Stop all. + * + * @param int $code + * @param mixed $log + */ + public static function stopAll(int $code = 0, mixed $log = ''): void + { + static::$status = static::STATUS_SHUTDOWN; + // For master process. + if (DIRECTORY_SEPARATOR === '/' && static::$masterPid === posix_getpid()) { + if ($log) { + static::log("Workerman[" . basename(static::$startFile) . "] $log"); + } + static::log("Workerman[" . basename(static::$startFile) . "] stopping" . ($code ? ", code [$code]" : '')); + $workerPidArray = static::getAllWorkerPids(); + // Send stop signal to all child processes. + $sig = static::getGracefulStop() ? SIGQUIT : SIGINT; + foreach ($workerPidArray as $workerPid) { + // Fix exit with status 2 for php8.2 + if ($sig === SIGINT && !static::$daemonize) { + Timer::add(1, posix_kill(...), [$workerPid, SIGINT], false); + } else { + posix_kill($workerPid, $sig); + } + if (!static::getGracefulStop()) { + Timer::add(ceil(static::$stopTimeout), posix_kill(...), [$workerPid, SIGKILL], false); + } + } + Timer::add(1, static::checkIfChildRunning(...)); + } // For child processes. + else { + if ($code && $log) { + static::log($log); + } + // Execute exit. + $workers = array_reverse(static::$workers); + array_walk($workers, static fn (Worker $worker) => $worker->stop(false)); + + $callback = function () use ($code, $workers) { + $allWorkerConnectionClosed = true; + if (!static::getGracefulStop()) { + foreach ($workers as $worker) { + foreach ($worker->connections as $connection) { + // Delay closing, waiting for data to be sent. + if (!$connection->getRecvBufferQueueSize() && !isset($connection->context->closeTimer)) { + $connection->context->closeTimer = Timer::delay(0.01, static fn () => $connection->close()); + } + $allWorkerConnectionClosed = false; + } + } + } + if ((!static::getGracefulStop() && $allWorkerConnectionClosed) || ConnectionInterface::$statistics['connection_count'] <= 0) { + static::$globalEvent?->stop(); + try { + // Ignore Swoole ExitException: Swoole exit. + exit($code); + /** @phpstan-ignore-next-line */ + } catch (Throwable) { + // do nothing + } + } + }; + Timer::repeat(0.01, $callback); + } + } + + /** + * check if child processes is really running + */ + protected static function checkIfChildRunning(): void + { + foreach (static::$pidMap as $workerId => $workerPidArray) { + foreach ($workerPidArray as $pid => $workerPid) { + if (!posix_kill($pid, 0)) { + unset(static::$pidMap[$workerId][$pid]); + } + } + } + } + + /** + * Get process status. + * + * @return int + */ + public static function getStatus(): int + { + return static::$status; + } + + /** + * If stop gracefully. + * + * @return bool + */ + public static function getGracefulStop(): bool + { + return static::$gracefulStop; + } + + /** + * + * Write statistics data to disk. + * + * @return void + */ + protected static function writeStatisticsToStatusFile(): void + { + // For master process. + if (static::$masterPid === posix_getpid()) { + $allWorkerInfo = []; + foreach (static::$pidMap as $workerId => $pidArray) { + $worker = static::$workers[$workerId]; + foreach ($pidArray as $pid) { + $allWorkerInfo[$pid] = ['name' => $worker->name, 'listen' => $worker->getSocketName()]; + } + } + file_put_contents(static::$statisticsFile, ''); + chmod(static::$statisticsFile, 0722); + file_put_contents(static::$statisticsFile, serialize($allWorkerInfo) . "\n", FILE_APPEND); + $loadavg = function_exists('sys_getloadavg') ? array_map(round(...), sys_getloadavg(), [2, 2, 2]) : ['-', '-', '-']; + file_put_contents(static::$statisticsFile, + (static::$daemonize ? "Start worker in DAEMON mode." : "Start worker in DEBUG mode.") . "\n", FILE_APPEND); + file_put_contents(static::$statisticsFile, + "---------------------------------------------------GLOBAL STATUS---------------------------------------------------------\n", FILE_APPEND); + file_put_contents(static::$statisticsFile, static::getVersionLine(), FILE_APPEND); + file_put_contents(static::$statisticsFile, 'start time:' . date('Y-m-d H:i:s', + static::$globalStatistics['start_timestamp']) + . ' run ' . floor((time() - static::$globalStatistics['start_timestamp']) / (24 * 60 * 60)) + . ' days ' . floor(((time() - static::$globalStatistics['start_timestamp']) % (24 * 60 * 60)) / (60 * 60)) + . " hours " . 'load average: ' . implode(", ", $loadavg) . "\n", FILE_APPEND); + file_put_contents(static::$statisticsFile, + count(static::$pidMap) . ' workers ' . count(static::getAllWorkerPids()) . " processes\n", + FILE_APPEND); + file_put_contents(static::$statisticsFile, + str_pad('name', static::getUiColumnLength('maxWorkerNameLength')) . " event-loop exit_status exit_count\n", FILE_APPEND); + foreach (static::$pidMap as $workerId => $workerPidArray) { + $worker = static::$workers[$workerId]; + if (isset(static::$globalStatistics['worker_exit_info'][$workerId])) { + foreach (static::$globalStatistics['worker_exit_info'][$workerId] as $workerExitStatus => $workerExitCount) { + file_put_contents(static::$statisticsFile, + str_pad($worker->name, static::getUiColumnLength('maxWorkerNameLength')) . " " . + str_pad($worker->context->eventLoopName, 14) . " " . + str_pad((string)$workerExitStatus, 16) . str_pad((string)$workerExitCount, 16) . "\n", FILE_APPEND); + } + } else { + file_put_contents(static::$statisticsFile, + str_pad($worker->name, static::getUiColumnLength('maxWorkerNameLength')) . " " . + str_pad($worker->context->eventLoopName, 14) . " " . + str_pad('0', 16) . str_pad('0', 16) . "\n", FILE_APPEND); + } + } + file_put_contents(static::$statisticsFile, + "---------------------------------------------------PROCESS STATUS--------------------------------------------------------\n", + FILE_APPEND); + file_put_contents(static::$statisticsFile, + "pid\tmemory " . str_pad('listening', static::getUiColumnLength('maxSocketNameLength')) . " " . str_pad('name', + static::getUiColumnLength('maxWorkerNameLength')) . " connections " . str_pad('send_fail', 9) . " " + . str_pad('timers', 8) . str_pad('total_request', 13) . " qps status\n", FILE_APPEND); + + foreach (static::getAllWorkerPids() as $workerPid) { + posix_kill($workerPid, SIGIOT); + } + return; + } + + reset(static::$workers); + /** @var static $worker */ + $worker = current(static::$workers); + $workerStatusStr = posix_getpid() . "\t" . str_pad(round(memory_get_usage() / (1024 * 1024), 2) . "M", 7) + . " " . str_pad($worker->getSocketName(), static::getUiColumnLength('maxSocketNameLength')) . " " + . str_pad(($worker->name === $worker->getSocketName() ? 'none' : $worker->name), static::getUiColumnLength('maxWorkerNameLength')) + . " "; + $workerStatusStr .= str_pad((string)ConnectionInterface::$statistics['connection_count'], 11) + . " " . str_pad((string)ConnectionInterface::$statistics['send_fail'], 9) + . " " . str_pad((string)static::$globalEvent->getTimerCount(), 7) + . " " . str_pad((string)ConnectionInterface::$statistics['total_request'], 13) . "\n"; + file_put_contents(static::$statisticsFile, $workerStatusStr, FILE_APPEND); + } + + /** + * Get UI column length + * + * @param $name + * @return int + */ + protected static function getUiColumnLength($name): int + { + return static::$uiLengthData[$name] ?? 0; + } + + /** + * Write statistics data to disk. + * + * @return void + */ + protected static function writeConnectionsStatisticsToStatusFile(): void + { + // For master process. + if (static::$masterPid === posix_getpid()) { + file_put_contents(static::$connectionsFile, ''); + chmod(static::$connectionsFile, 0722); + file_put_contents(static::$connectionsFile, "--------------------------------------------------------------------- WORKERMAN CONNECTION STATUS --------------------------------------------------------------------------------\n", FILE_APPEND); + file_put_contents(static::$connectionsFile, "PID Worker CID Trans Protocol ipv4 ipv6 Recv-Q Send-Q Bytes-R Bytes-W Status Local Address Foreign Address\n", FILE_APPEND); + foreach (static::getAllWorkerPids() as $workerPid) { + posix_kill($workerPid, SIGIO); + } + return; + } + + // For child processes. + $bytesFormat = function ($bytes) { + if ($bytes > 1024 * 1024 * 1024 * 1024) { + return round($bytes / (1024 * 1024 * 1024 * 1024), 1) . "TB"; + } + if ($bytes > 1024 * 1024 * 1024) { + return round($bytes / (1024 * 1024 * 1024), 1) . "GB"; + } + if ($bytes > 1024 * 1024) { + return round($bytes / (1024 * 1024), 1) . "MB"; + } + if ($bytes > 1024) { + return round($bytes / (1024), 1) . "KB"; + } + return $bytes . "B"; + }; + + $pid = posix_getpid(); + $str = ''; + reset(static::$workers); + $currentWorker = current(static::$workers); + $defaultWorkerName = $currentWorker->name; + + foreach (TcpConnection::$connections as $connection) { + /** @var TcpConnection $connection */ + $transport = $connection->transport; + $ipv4 = $connection->isIpV4() ? ' 1' : ' 0'; + $ipv6 = $connection->isIpV6() ? ' 1' : ' 0'; + $recvQ = $bytesFormat($connection->getRecvBufferQueueSize()); + $sendQ = $bytesFormat($connection->getSendBufferQueueSize()); + $localAddress = trim($connection->getLocalAddress()); + $remoteAddress = trim($connection->getRemoteAddress()); + $state = $connection->getStatus(false); + $bytesRead = $bytesFormat($connection->bytesRead); + $bytesWritten = $bytesFormat($connection->bytesWritten); + $id = $connection->id; + $protocol = $connection->protocol ?: $connection->transport; + $pos = strrpos($protocol, '\\'); + if ($pos) { + $protocol = substr($protocol, $pos + 1); + } + if (strlen($protocol) > 15) { + $protocol = substr($protocol, 0, 13) . '..'; + } + $workerName = isset($connection->worker) ? $connection->worker->name : $defaultWorkerName; + if (strlen($workerName) > 14) { + $workerName = substr($workerName, 0, 12) . '..'; + } + $str .= str_pad((string)$pid, 9) . str_pad($workerName, 16) . str_pad((string)$id, 10) . str_pad($transport, 8) + . str_pad($protocol, 16) . str_pad($ipv4, 7) . str_pad($ipv6, 7) . str_pad($recvQ, 13) + . str_pad($sendQ, 13) . str_pad($bytesRead, 13) . str_pad($bytesWritten, 13) . ' ' + . str_pad($state, 14) . ' ' . str_pad($localAddress, 22) . ' ' . str_pad($remoteAddress, 22) . "\n"; + } + if ($str) { + file_put_contents(static::$connectionsFile, $str, FILE_APPEND); + } + } + + /** + * Check errors when current process exited. + * + * @return void + */ + protected static function checkErrors(): void + { + if (static::STATUS_SHUTDOWN !== static::$status) { + $errorMsg = DIRECTORY_SEPARATOR === '/' ? 'Worker[' . posix_getpid() . '] process terminated' : 'Worker process terminated'; + $errors = error_get_last(); + if ($errors && ($errors['type'] === E_ERROR || + $errors['type'] === E_PARSE || + $errors['type'] === E_CORE_ERROR || + $errors['type'] === E_COMPILE_ERROR || + $errors['type'] === E_RECOVERABLE_ERROR) + ) { + $errorMsg .= ' with ERROR: ' . static::getErrorType($errors['type']) . " \"{$errors['message']} in {$errors['file']} on line {$errors['line']}\""; + } + static::log($errorMsg); + } + } + + /** + * Get error message by error code. + * + * @param int $type + * @return string + */ + protected static function getErrorType(int $type): string + { + return self::ERROR_TYPE[$type] ?? ''; + } + + /** + * Log. + * + * @param Stringable|string $msg + * @param bool $decorated + * @return void + */ + public static function log(Stringable|string $msg, bool $decorated = false): void + { + $msg = trim((string)$msg); + + if (!static::$daemonize) { + static::safeEcho("$msg\n", $decorated); + } + + if (isset(static::$logFile)) { + $pid = DIRECTORY_SEPARATOR === '/' ? posix_getpid() : 1; + file_put_contents(static::$logFile, sprintf("%s pid:%d %s\n", date('Y-m-d H:i:s'), $pid, $msg), FILE_APPEND | LOCK_EX); + + // Check the file size and truncate if it exceeds max size + if (!empty(static::$logFileMaxSize) && ($fileSize = filesize(static::$logFile)) > static::$logFileMaxSize) { + // Open files + $source = fopen(static::$logFile, 'r'); + + if (!$source) { + return; + } else if (!flock($source, LOCK_EX)) { + fclose($source); + return; + } + + $newFile = static::$logFile . '.tmp'; + $destination = fopen($newFile, 'w'); + + if (!$destination) { + flock($source, LOCK_UN); + fclose($source); + return; + } + + // Move to the halfway point in the source file + $halfwayPoint = (int)($fileSize / 2); + fseek($source, $halfwayPoint); + + // Find the next newline character to ensure we don't cut in the middle of a line + while (($char = fgetc($source)) !== false) { + if ($char === "\n") { + break; + } + } + + // Copy the second half into the new file + while (!feof($source)) { + fwrite($destination, fread($source, 8192)); // Read and write 8KB chunks + } + + // Replace the old file with the new truncated file + rename($newFile, static::$logFile); + + // Close both files + flock($source, LOCK_UN); + fclose($source); + fclose($destination); + } + } + } + + /** + * Safe Echo. + * + * @param string $msg + * @param bool $decorated + * @return void + */ + public static function safeEcho(string $msg, bool $decorated = false): void + { + if ((static::$outputDecorated ?? false) && $decorated) { + $line = "\033[1A\n\033[K"; + $white = "\033[47;30m"; + $green = "\033[32;40m"; + $end = "\033[0m"; + } else { + $line = ''; + $white = ''; + $green = ''; + $end = ''; + } + + $msg = str_replace(['', '', ''], [$line, $white, $green], $msg); + $msg = str_replace(['', '', ''], $end, $msg); + set_error_handler(static fn (): bool => true); + if (!feof(self::$outputStream)) { + fwrite(self::$outputStream, $msg); + fflush(self::$outputStream); + } + restore_error_handler(); + } + + /** + * Listen. + * + * @param bool $autoAccept + * @return void + */ + public function listen(bool $autoAccept = true): void + { + if (!$this->socketName) { + return; + } + + if (!$this->mainSocket) { + + $localSocket = $this->parseSocketAddress(); + + // Flag. + $flags = $this->transport === 'udp' ? STREAM_SERVER_BIND : STREAM_SERVER_BIND | STREAM_SERVER_LISTEN; + $errNo = 0; + $errMsg = ''; + // SO_REUSEPORT. + if ($this->reusePort && DIRECTORY_SEPARATOR !== '\\') { + stream_context_set_option($this->socketContext, 'socket', 'so_reuseport', 1); + } + + // Create an Internet or Unix domain server socket. + $this->mainSocket = stream_socket_server($localSocket, $errNo, $errMsg, $flags, $this->socketContext); + if (!$this->mainSocket) { + throw new RuntimeException($errMsg); + } + + if ($this->transport === 'ssl') { + stream_socket_enable_crypto($this->mainSocket, false); + } elseif ($this->transport === 'unix') { + $socketFile = substr($localSocket, 7); + if ($this->user) { + chown($socketFile, $this->user); + } + if ($this->group) { + chgrp($socketFile, $this->group); + } + } + + // Try to open keepalive for tcp and disable Nagle algorithm. + if (function_exists('socket_import_stream') && self::BUILD_IN_TRANSPORTS[$this->transport] === 'tcp') { + set_error_handler(static fn (): bool => true); + $socket = socket_import_stream($this->mainSocket); + socket_set_option($socket, SOL_SOCKET, SO_KEEPALIVE, 1); + socket_set_option($socket, SOL_TCP, TCP_NODELAY, 1); + if (defined('TCP_KEEPIDLE') && defined('TCP_KEEPINTVL') && defined('TCP_KEEPCNT')) { + socket_set_option($socket, SOL_TCP, TCP_KEEPIDLE, TcpConnection::TCP_KEEPALIVE_INTERVAL); + socket_set_option($socket, SOL_TCP, TCP_KEEPINTVL, TcpConnection::TCP_KEEPALIVE_INTERVAL); + socket_set_option($socket, SOL_TCP, TCP_KEEPCNT, 1); + } + restore_error_handler(); + } + + // Non blocking. + stream_set_blocking($this->mainSocket, false); + } + + if ($autoAccept) { + $this->resumeAccept(); + } + } + + /** + * Unlisten. + * + * @return void + */ + public function unlisten(): void + { + $this->pauseAccept(); + if ($this->mainSocket) { + set_error_handler(static fn (): bool => true); + fclose($this->mainSocket); + restore_error_handler(); + $this->mainSocket = null; + } + } + + /** + * Check port available. + * + * @return void + */ + protected static function checkPortAvailable(): void + { + foreach (static::$workers as $worker) { + $socketName = $worker->getSocketName(); + if (DIRECTORY_SEPARATOR === '/' // if linux + && static::$status === static::STATUS_STARTING // only for starting status + && $worker->transport === 'tcp' // if tcp socket + && !str_starts_with($socketName, 'unix') // if not unix socket + && !str_starts_with($socketName, 'udp')) { // if not udp socket + + $address = parse_url($socketName); + if (isset($address['host']) && isset($address['port'])) { + $address = "tcp://{$address['host']}:{$address['port']}"; + $server = null; + set_error_handler(function ($code, $msg) { + throw new RuntimeException($msg); + }); + $server = stream_socket_server($address, $code, $msg); + if ($server) { + fclose($server); + } + restore_error_handler(); + } + } + } + } + + /** + * Parse local socket address. + */ + protected function parseSocketAddress(): ?string + { + if (!$this->socketName) { + return null; + } + // Get the application layer communication protocol and listening address. + [$scheme, $address] = explode(':', $this->socketName, 2); + // Check application layer protocol class. + if (!isset(self::BUILD_IN_TRANSPORTS[$scheme])) { + // Validate scheme contains only safe characters for class name resolution. + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $scheme)) { + throw new RuntimeException("Invalid protocol scheme '$scheme'"); + } + $scheme = ucfirst($scheme); + $this->protocol = 'Protocols\\' . $scheme; + if (!class_exists($this->protocol)) { + $this->protocol = "Workerman\\Protocols\\$scheme"; + if (!class_exists($this->protocol)) { + throw new RuntimeException("class \\Protocols\\$scheme not exist"); + } + } + + if (!isset(self::BUILD_IN_TRANSPORTS[$this->transport])) { + throw new RuntimeException('Bad worker->transport ' . var_export($this->transport, true)); + } + } else if ($this->transport === 'tcp') { + $this->transport = $scheme; + } + //local socket + return self::BUILD_IN_TRANSPORTS[$this->transport] . ":" . $address; + } + + /** + * Pause accept new connections. + * + * @return void + */ + public function pauseAccept(): void + { + if (static::$globalEvent !== null && !$this->pauseAccept && $this->mainSocket !== null) { + static::$globalEvent->offReadable($this->mainSocket); + $this->pauseAccept = true; + } + } + + /** + * Resume accept new connections. + * + * @return void + */ + public function resumeAccept(): void + { + // Register a listener to be notified when server socket is ready to read. + if (static::$globalEvent !== null && ($this->pauseAccept === null || $this->pauseAccept === true) && $this->mainSocket !== null) { + if ($this->transport !== 'udp') { + static::$globalEvent->onReadable($this->mainSocket, $this->acceptTcpConnection(...)); + } else { + static::$globalEvent->onReadable($this->mainSocket, $this->acceptUdpConnection(...)); + } + $this->pauseAccept = false; + } + } + + /** + * Get socket name. + * + * @return string + */ + public function getSocketName(): string + { + return $this->socketName ? lcfirst($this->socketName) : 'none'; + } + + /** + * Run worker instance. + * + * @return void + * @throws Throwable + */ + public function run(): void + { + $this->listen(!$this->onWorkerStart); + + if (!$this->onWorkerStart) { + return; + } + + // Try to emit onWorkerStart callback. + $callback = function() { + try { + ($this->onWorkerStart)($this); + } catch (Throwable $e) { + // Avoid rapid infinite loop exit. + sleep(1); + static::stopAll(250, $e); + } finally { + if ($this->pauseAccept === null) { + $this->resumeAccept(); + } + Context::destroy(); + } + }; + + match (Worker::$eventLoopClass) { + Swoole::class, + Swow::class, + Fiber::class => Coroutine::create($callback), + + default => (new \Fiber($callback))->start(), + }; + } + + /** + * Stop current worker instance. + * + * @param bool $force + * @return void + */ + public function stop(bool $force = true): void + { + if ($this->stopping === true) { + return; + } + // Try to emit onWorkerStop callback. + if ($this->onWorkerStop) { + try { + ($this->onWorkerStop)($this); + } catch (Throwable $e) { + static::log($e); + } + } + // Remove listener for server socket. + $this->unlisten(); + // Close all connections for the worker. + if (!static::getGracefulStop()) { + foreach ($this->connections as $connection) { + if ($force || !$connection->getRecvBufferQueueSize()) { + $connection->close(); + } + } + } + // Clear callback. + $this->onMessage = $this->onClose = $this->onError = $this->onBufferDrain = $this->onBufferFull = null; + $this->stopping = true; + } + + /** + * Accept a connection. + * + * @param resource $socket + * @return void + */ + protected function acceptTcpConnection(mixed $socket): void + { + // Accept a connection on server socket. + set_error_handler(static fn (): bool => true); + $newSocket = stream_socket_accept($socket, 0, $remoteAddress); + restore_error_handler(); + + // Thundering herd. + if (!$newSocket) { + return; + } + + // TcpConnection. + $connection = new TcpConnection(static::$globalEvent, $newSocket, $remoteAddress); + $this->connections[$connection->id] = $connection; + $connection->worker = $this; + $connection->protocol = $this->protocol; + $connection->transport = $this->transport; + $connection->onMessage = $this->onMessage; + $connection->onClose = $this->onClose; + $connection->onError = $this->onError; + $connection->onBufferDrain = $this->onBufferDrain; + $connection->onBufferFull = $this->onBufferFull; + + // Try to emit onConnect callback. + if ($this->onConnect) { + try { + ($this->onConnect)($connection); + } catch (Throwable $e) { + static::stopAll(250, $e); + } + } + } + + /** + * For udp package. + * + * @param resource $socket + * @return void + */ + protected function acceptUdpConnection(mixed $socket): void + { + set_error_handler(static fn (): bool => true); + $recvBuffer = stream_socket_recvfrom($socket, UdpConnection::MAX_UDP_PACKAGE_SIZE, 0, $remoteAddress); + restore_error_handler(); + if (false === $recvBuffer || empty($remoteAddress)) { + return; + } + // UdpConnection. + $connection = new UdpConnection($socket, $remoteAddress); + $connection->protocol = $this->protocol; + $messageCallback = $this->onMessage; + if ($messageCallback) { + try { + if ($this->protocol !== null) { + $parser = $this->protocol; + if ($parser && method_exists($parser, 'input')) { + while ($recvBuffer !== '') { + $len = $parser::input($recvBuffer, $connection); + if ($len === 0) { + return; + } + $package = substr($recvBuffer, 0, $len); + $recvBuffer = substr($recvBuffer, $len); + $data = $parser::decode($package, $connection); + if ($data === false) { + continue; + } + $messageCallback($connection, $data); + } + } else { + $data = $parser::decode($recvBuffer, $connection); + // Discard bad packets. + if ($data === false) { + return; + } + $messageCallback($connection, $data); + } + } else { + $messageCallback($connection, $recvBuffer); + } + ConnectionInterface::$statistics['total_request']++; + } catch (Throwable $e) { + static::stopAll(250, $e); + } + } + } + + /** + * Check master process is alive + * + * @param int $masterPid + * @return bool + */ + protected static function checkMasterIsAlive(int $masterPid): bool + { + if (empty($masterPid)) { + return false; + } + + $masterIsAlive = posix_kill($masterPid, 0) && posix_getpid() !== $masterPid; + if (!$masterIsAlive) { + static::log("Master pid:$masterPid is not alive"); + return false; + } + + $cmdline = "/proc/$masterPid/cmdline"; + if (!is_readable($cmdline)) { + return true; + } + + $content = file_get_contents($cmdline); + if (empty($content)) { + return true; + } + + return str_contains($content, 'WorkerMan') || str_contains($content, 'php'); + } + + /** + * If worker is running. + * + * @return bool + */ + public static function isRunning(): bool + { + return Worker::$status !== Worker::STATUS_INITIAL; + } +}