This commit is contained in:
EchoNoch 2026-05-28 20:19:28 +08:00
commit 070fad058f
124 changed files with 22713 additions and 0 deletions

61
.env.example Normal file
View File

@ -0,0 +1,61 @@
[config]
APP_ENV=dev
# 应用运行环境;可选值通常为 dev、test、prodprod 下应启用更严格的认证与日志策略。
LOG_LEVEL=debug
# 日志级别;建议值为 debug、info、warning、error当前 MVP 预留该配置用于后续日志工厂。
AUDIT_LOG=runtime/audit.log
# 审计日志文件路径;建议使用 runtime/*.logMVP 会按 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 或 userpassuserpass 使用 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当前已实现 tcpudp/kcp 为预留实现。

65
bin/client-agent.php Executable file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use LayLink\Agent\AgentClient;
use LayLink\Util\Env;
use Workerman\Worker;
require dirname(__DIR__) . '/vendor/autoload.php';
Env::load(dirname(__DIR__) . '/.env');
Worker::$logFile = dirname(__DIR__) . '/runtime/workerman.log';
Worker::$pidFile = dirname(__DIR__) . '/runtime/client-agent.pid';
$nodeId = Env::get('NODE_ID', 'client-01');
$bootAgent = function (string $protocol, string $listen, string $name) use ($nodeId): void {
$agent = new AgentClient(
$listen,
$protocol,
Env::get('POP_SERVER_ADDRESS', 'tcp://127.0.0.1:9001'),
$nodeId,
Env::get('NODE_TYPE', 'client'),
Env::get('NODE_TOKEN', 'CHANGE_ME'),
Env::get('NODE_ZONE', 'corp'),
Env::get('AGENT_TRANSPORT_PROTOCOL', 'tcp'),
Env::get('CLIENT_AGENT_AUTH_TOKEN', 'dev-token'),
Env::get('CLIENT_AGENT_USER_ID', 'admin'),
Env::get('CLIENT_AGENT_SOCKS5_AUTH_MODE', 'no-auth'),
Env::get('CLIENT_AGENT_SOCKS5_USERNAME', ''),
Env::get('CLIENT_AGENT_SOCKS5_PASSWORD', ''),
$protocol === 'socks5'
? Env::get('CLIENT_AGENT_SOCKS5_UDP_LISTEN_IP', '127.0.0.1') . ':' . Env::get('CLIENT_AGENT_SOCKS5_UDP_LISTEN_PORT', '1081')
: null,
Env::get('CLIENT_AGENT_SOCKS5_UDP_ADVERTISE_IP', Env::get('CLIENT_AGENT_SOCKS5_LISTEN_IP', '127.0.0.1')),
);
$agent->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();

25
bin/pop-server.php Executable file
View File

@ -0,0 +1,25 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use LayLink\Server\PopServer;
use LayLink\Util\Env;
use Workerman\Worker;
require dirname(__DIR__) . '/vendor/autoload.php';
Env::load(dirname(__DIR__) . '/.env');
Worker::$logFile = dirname(__DIR__) . '/runtime/workerman.log';
Worker::$pidFile = dirname(__DIR__) . '/runtime/pop-server.pid';
$server = new PopServer(
Env::get('POP_AGENT_LISTEN', '0.0.0.0:9001'),
require dirname(__DIR__) . '/config/nodes.php',
require dirname(__DIR__) . '/config/policies.php',
Env::csv('POP_ALLOWED_AGENT_TRANSPORTS', ['tcp']),
Env::get('AUDIT_LOG', dirname(__DIR__) . '/runtime/audit.log'),
);
$server->boot();
Worker::runAll();

10
composer.json Normal file
View File

@ -0,0 +1,10 @@
{
"autoload": {
"psr-4": {
"LayLink\\": "src/"
}
},
"require": {
"workerman/workerman": "^5.2"
}
}

135
composer.lock generated Normal file
View File

@ -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": "<v1.0.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.10",
"mockery/mockery": "^1.6",
"pestphp/pest": "^2.36 || ^3 || ^4",
"phpstan/phpstan": "^2.1"
},
"suggest": {
"ext-event": "For better performance. "
},
"type": "library",
"autoload": {
"psr-4": {
"Workerman\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "walkor",
"email": "walkor@workerman.net",
"homepage": "https://www.workerman.net",
"role": "Developer"
}
],
"description": "An asynchronous event driven PHP framework for easily building fast, scalable network applications.",
"homepage": "https://www.workerman.net",
"keywords": [
"asynchronous",
"event-loop",
"framework",
"http"
],
"support": {
"email": "walkor@workerman.net",
"forum": "https://www.workerman.net/questions",
"issues": "https://github.com/walkor/workerman/issues",
"source": "https://github.com/walkor/workerman",
"wiki": "https://www.workerman.net/doc/workerman/"
},
"funding": [
{
"url": "https://opencollective.com/workerman",
"type": "open_collective"
},
{
"url": "https://www.patreon.com/walkor",
"type": "patreon"
}
],
"time": "2026-05-05T14:33:37+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

14
config/nodes.php Normal file
View File

@ -0,0 +1,14 @@
<?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,
],
];

22
config/policies.php Normal file
View File

@ -0,0 +1,22 @@
<?php
return [
[
'policy_id' => '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,
],
];

5
config/routes.php Normal file
View File

@ -0,0 +1,5 @@
<?php
return [
'default' => 'deny',
];

1241
contract.md Normal file

File diff suppressed because it is too large Load Diff

373
readme.md Normal file
View File

@ -0,0 +1,373 @@
# LayLink
LayLink 是一个基于 PHP Workerman 的策略控制型四层反向访问网关。
它不是 VPN。客户端连接 Client Agent请求访问某个 TCP 目标Client Agent 使用 LayLink Frame 协议连接 POP ServerPOP 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 ServerPOP 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、限流和更完整的审计存储。

36
runtime/workerman.log Normal file
View File

@ -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

79
scripts/verify-socks5.sh Executable file
View File

@ -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"

753
src/Agent/AgentClient.php Normal file
View File

@ -0,0 +1,753 @@
<?php
declare(strict_types=1);
namespace LayLink\Agent;
use LayLink\Protocol\Frame;
use LayLink\Protocol\FrameCodec;
use LayLink\Protocol\FrameParser;
use LayLink\Protocol\FrameType;
use LayLink\Util\Uuid;
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Connection\TcpConnection;
use Workerman\Connection\UdpConnection;
use Workerman\Timer;
use Workerman\Worker;
final class AgentClient
{
private ?AsyncTcpConnection $pop = null;
private ?FrameParser $parser = null;
private bool $authenticated = false;
/** @var array<int, string> */
private array $initialBuffers = [];
/** @var array<int, string> */
private array $connectionSessionIds = [];
/** @var array<string, TcpConnection> */
private array $clients = [];
/** @var array<string, string> */
private array $sessionStates = [];
/** @var array<string, string> */
private array $pendingData = [];
/** @var array<int, string> */
private array $connectionStages = [];
/** @var array<string, string> */
private array $sessionIngressProtocols = [];
/** @var array<string, UdpConnection> */
private array $udpClients = [];
/** @var array<string, array{client_address: string, target_host: string, target_port: int}> */
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));
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace LayLink\Agent;
use LayLink\Auth\PolicyChecker;
final class TargetConnector
{
public function __construct(private readonly array $nodeConfig)
{
}
public function isAllowed(string $host, int $port): bool
{
if (!in_array($port, $this->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);
}
}

28
src/Audit/AuditLogger.php Normal file
View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace LayLink\Audit;
final class AuditLogger
{
public function __construct(private readonly string $path)
{
$dir = dirname($this->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,
);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace LayLink\Auth;
final class ClientAuthenticator
{
public function authenticate(array $request): array
{
$token = (string)($request['auth_token'] ?? '');
if (!hash_equals('dev-token', $token)) {
return ['ok' => false, 'reason' => 'invalid_auth'];
}
return ['ok' => true, 'user_id' => (string)($request['user_id'] ?? 'admin')];
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace LayLink\Auth;
final class NodeAuthenticator
{
/**
* @param string[] $allowedTransports
*/
public function __construct(
private readonly array $nodes,
private readonly array $allowedTransports = ['tcp'],
) {
}
public function authenticate(array $payload): array
{
$nodeId = (string)($payload['node_id'] ?? '');
$nodeType = (string)($payload['node_type'] ?? '');
$token = (string)($payload['node_token'] ?? '');
$transport = strtolower((string)($payload['transport_protocol'] ?? 'tcp'));
if ($nodeId === '' || !isset($this->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]];
}
}

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace LayLink\Auth;
final class PolicyChecker
{
public function __construct(private readonly array $policies)
{
}
public function find(string $userId, string $host, int $port, string $protocol): ?array
{
foreach ($this->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);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace LayLink\Node;
use Workerman\Connection\TcpConnection;
final class NodeConnection
{
public int $lastHeartbeat;
public int $activeSessions = 0;
public function __construct(
public readonly string $nodeId,
public readonly string $nodeType,
public readonly string $nodeZone,
public readonly TcpConnection $connection,
) {
$this->lastHeartbeat = time();
}
}

48
src/Node/NodeRegistry.php Normal file
View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace LayLink\Node;
use Workerman\Connection\TcpConnection;
final class NodeRegistry
{
/** @var array<string, NodeConnection> */
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);
}
}

26
src/Protocol/Frame.php Normal file
View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace LayLink\Protocol;
final class Frame
{
public function __construct(
public readonly string $type,
public readonly ?string $sessionId = null,
public readonly array $payload = [],
public readonly int $version = 1,
) {
}
public function toArray(): array
{
return [
'version' => $this->version,
'type' => $this->type,
'session_id' => $this->sessionId,
'payload' => $this->payload,
];
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace LayLink\Protocol;
use InvalidArgumentException;
final class FrameCodec
{
public const MAX_FRAME_LENGTH = 16 * 1024 * 1024;
public static function encode(Frame $frame): string
{
$json = json_encode($frame->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),
);
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace LayLink\Protocol;
use RuntimeException;
final class FrameParser
{
private string $buffer = '';
/**
* @return Frame[]
*/
public function push(string $chunk): array
{
$this->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;
}
}

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace LayLink\Protocol;
final class FrameType
{
public const AUTH = 'AUTH';
public const AUTH_OK = 'AUTH_OK';
public const AUTH_FAIL = 'AUTH_FAIL';
public const PING = 'PING';
public const PONG = 'PONG';
public const OPEN = 'OPEN';
public const OPEN_OK = 'OPEN_OK';
public const OPEN_FAIL = 'OPEN_FAIL';
public const DATA = 'DATA';
public const UDP_DATA = 'UDP_DATA';
public const CLOSE = 'CLOSE';
public const ERROR = 'ERROR';
public const WINDOW = 'WINDOW';
/**
* @return array<string, string>
*/
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.',
];
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace LayLink\Route;
final class RouteDecision
{
public function __construct(
public readonly bool $allowed,
public readonly string $routeType,
public readonly ?string $nodeId,
public readonly ?string $policyId,
public readonly ?string $reason = null,
) {
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace LayLink\Route;
use LayLink\Auth\PolicyChecker;
use LayLink\Node\NodeRegistry;
final class RouteResolver
{
public function __construct(
private readonly PolicyChecker $policyChecker,
private readonly NodeRegistry $nodeRegistry,
) {
}
public function resolve(string $userId, string $host, int $port, string $protocol, ?string $routeHint): RouteDecision
{
$policy = $this->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']);
}
}

View File

@ -0,0 +1,349 @@
<?php
declare(strict_types=1);
namespace LayLink\Server;
use LayLink\Audit\AuditLogger;
use LayLink\Auth\ClientAuthenticator;
use LayLink\Auth\NodeAuthenticator;
use LayLink\Node\NodeConnection;
use LayLink\Node\NodeRegistry;
use LayLink\Protocol\Frame;
use LayLink\Protocol\FrameCodec;
use LayLink\Protocol\FrameParser;
use LayLink\Protocol\FrameType;
use LayLink\Route\RouteResolver;
use LayLink\Session\SessionManager;
use LayLink\Session\TunnelSession;
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Connection\AsyncUdpConnection;
use Workerman\Connection\TcpConnection;
use Workerman\Timer;
use Workerman\Worker;
final class AgentListener
{
/** @var array<int, FrameParser> */
private array $parsers = [];
/** @var array<int, string> */
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));
}
}

49
src/Server/PopServer.php Normal file
View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace LayLink\Server;
use LayLink\Audit\AuditLogger;
use LayLink\Auth\ClientAuthenticator;
use LayLink\Auth\NodeAuthenticator;
use LayLink\Auth\PolicyChecker;
use LayLink\Node\NodeRegistry;
use LayLink\Route\RouteResolver;
use LayLink\Session\SessionManager;
use Workerman\Worker;
final class PopServer
{
public readonly NodeRegistry $nodes;
public readonly SessionManager $sessions;
public readonly AuditLogger $audit;
public function __construct(
private readonly string $agentListen,
private readonly array $nodeConfig,
private readonly array $policies,
private readonly array $allowedAgentTransports,
string $auditLog,
) {
$this->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,
);
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace LayLink\Session;
final class SessionManager
{
/** @var array<string, TunnelSession> */
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);
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace LayLink\Session;
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Connection\TcpConnection;
final class TunnelSession
{
public const NEW = 'NEW';
public const OPENING = 'OPENING';
public const OPEN = 'OPEN';
public const CLOSING = 'CLOSING';
public const CLOSED = 'CLOSED';
public const FAILED = 'FAILED';
public string $state = self::NEW;
public ?TcpConnection $client = null;
public ?TcpConnection $agent = null;
public ?AsyncTcpConnection $target = null;
public ?string $nodeId = null;
public string $startTime;
public int $startedAtMs;
public int $bytesClientToTarget = 0;
public int $bytesTargetToClient = 0;
public function __construct(
public readonly string $sessionId,
public readonly string $userId,
public readonly string $sourceIp,
public readonly string $targetHost,
public readonly int $targetPort,
public readonly string $protocol,
public readonly string $routeType,
public readonly ?string $policyId,
) {
$this->startTime = date(DATE_ATOM);
$this->startedAtMs = (int)floor(microtime(true) * 1000);
}
}

67
src/Util/Env.php Normal file
View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace LayLink\Util;
final class Env
{
public static function load(string $path): void
{
if (!is_file($path)) {
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
continue;
}
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value, " \t\n\r\0\x0B\"'");
if ($key !== '' && getenv($key) === false) {
putenv($key . '=' . $value);
$_ENV[$key] = $value;
}
}
}
public static function get(string $key, string $default): string
{
$value = getenv($key);
return $value === false || $value === '' ? $default : $value;
}
public static function bool(string $key, bool $default): bool
{
$value = getenv($key);
if ($value === false || trim($value) === '') {
return $default;
}
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
}
/**
* @return string[]
*/
public static function csv(string $key, array $default): array
{
$value = getenv($key);
if ($value === false || trim($value) === '') {
return $default;
}
return array_values(array_filter(array_map(
static fn (string $item): string => strtolower(trim($item)),
explode(',', $value),
)));
}
}

17
src/Util/Uuid.php Normal file
View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace LayLink\Util;
final class Uuid
{
public static function v4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
}

22
vendor/autoload.php vendored Normal file
View File

@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit54847d6030d29731b0e767d050d22a36::getLoader();

579
vendor/composer/ClassLoader.php vendored Normal file
View File

@ -0,0 +1,579 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* 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 <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @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<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
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<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $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>|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>|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>|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>|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<string, self>
*/
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);
}
}

396
vendor/composer/InstalledVersions.php vendored Normal file
View File

@ -0,0 +1,396 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* 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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
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<string>
*/
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<string>
*/
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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
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<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $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<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $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<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}
return $installed;
}
}

21
vendor/composer/LICENSE vendored Normal file
View File

@ -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.

10
vendor/composer/autoload_classmap.php vendored Normal file
View File

@ -0,0 +1,10 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
);

View File

@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
);

12
vendor/composer/autoload_psr4.php vendored Normal file
View File

@ -0,0 +1,12 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Workerman\\Coroutine\\' => array($vendorDir . '/workerman/coroutine/src'),
'Workerman\\' => array($vendorDir . '/workerman/workerman/src', $vendorDir . '/workerman/coroutine/src'),
'LayLink\\' => array($baseDir . '/src'),
);

38
vendor/composer/autoload_real.php vendored Normal file
View File

@ -0,0 +1,38 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInit54847d6030d29731b0e767d050d22a36
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
/**
* @return \Composer\Autoload\ClassLoader
*/
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
require __DIR__ . '/platform_check.php';
spl_autoload_register(array('ComposerAutoloaderInit54847d6030d29731b0e767d050d22a36', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
spl_autoload_unregister(array('ComposerAutoloaderInit54847d6030d29731b0e767d050d22a36', 'loadClassLoader'));
require __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInit54847d6030d29731b0e767d050d22a36::getInitializer($loader));
$loader->register(true);
return $loader;
}
}

50
vendor/composer/autoload_static.php vendored Normal file
View File

@ -0,0 +1,50 @@
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInit54847d6030d29731b0e767d050d22a36
{
public static $prefixLengthsPsr4 = array (
'W' =>
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);
}
}

128
vendor/composer/installed.json vendored Normal file
View File

@ -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": "<v1.0.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.10",
"mockery/mockery": "^1.6",
"pestphp/pest": "^2.36 || ^3 || ^4",
"phpstan/phpstan": "^2.1"
},
"suggest": {
"ext-event": "For better performance. "
},
"time": "2026-05-05T14:33:37+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
"psr-4": {
"Workerman\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "walkor",
"email": "walkor@workerman.net",
"homepage": "https://www.workerman.net",
"role": "Developer"
}
],
"description": "An asynchronous event driven PHP framework for easily building fast, scalable network applications.",
"homepage": "https://www.workerman.net",
"keywords": [
"asynchronous",
"event-loop",
"framework",
"http"
],
"support": {
"email": "walkor@workerman.net",
"forum": "https://www.workerman.net/questions",
"issues": "https://github.com/walkor/workerman/issues",
"source": "https://github.com/walkor/workerman",
"wiki": "https://www.workerman.net/doc/workerman/"
},
"funding": [
{
"url": "https://opencollective.com/workerman",
"type": "open_collective"
},
{
"url": "https://www.patreon.com/walkor",
"type": "patreon"
}
],
"install-path": "../workerman/workerman"
}
],
"dev": true,
"dev-package-names": []
}

41
vendor/composer/installed.php vendored Normal file
View File

@ -0,0 +1,41 @@
<?php return array(
'root' => 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,
),
),
);

25
vendor/composer/platform_check.php vendored Normal file
View File

@ -0,0 +1,25 @@
<?php
// platform_check.php @generated by Composer
$issues = array();
if (!(PHP_VERSION_ID >= 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)
);
}

5
vendor/workerman/coroutine/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
composer.lock
vendor
.idea
tests/.phpunit.result.cache
tests/workerman.log

21
vendor/workerman/coroutine/LICENSE vendored Normal file
View File

@ -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.

3
vendor/workerman/coroutine/README.md vendored Normal file
View File

@ -0,0 +1,3 @@
# Workerman coroutine library
This is Workerman's coroutine library, which includes `Coroutine` `Channel` `Barrier` `Parallel` `Pool`.

View File

@ -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
}

View File

@ -0,0 +1,65 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}

View File

@ -0,0 +1,39 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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;
}

View File

@ -0,0 +1,85 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}

View File

@ -0,0 +1,39 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}

View File

@ -0,0 +1,39 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}

View File

@ -0,0 +1,114 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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;
}

View File

@ -0,0 +1,252 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}

View File

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Workerman\Coroutine\Channel;
class Memory implements ChannelInterface
{
private array $data = [];
private int $capacity;
private bool $closed = false;
public function __construct(int $capacity = 0)
{
$this->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;
}
}

View File

@ -0,0 +1,98 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}

View File

@ -0,0 +1,108 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}

View File

@ -0,0 +1,90 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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<ContextInterface>
*/
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();

View File

@ -0,0 +1,50 @@
<?php
namespace Workerman\Coroutine\Context;
use ArrayObject;
/**
* Interface ContextInterface
*/
interface ContextInterface
{
/**
* Get the value from the context with the specified name.
* If the name does not exist, return the default value.
*
* @param string|null $name The name of the value to get.
* @param mixed $default The default value to return if the name does not exist.
* @return mixed The value from the context or the default value.
*/
public static function get(?string $name = null, mixed $default = null): mixed;
/**
* Set the value in the context with the specified name.
*
* @param string $name The name of the value to set.
* @param mixed $value The value to set.
*/
public static function set(string $name, mixed $value): void;
/**
* Check if the specified name exists in the context.
*
* @param string $name The name to check.
* @return bool True if the name exists, otherwise false.
*/
public static function has(string $name): bool;
/**
* Initialize the context with an array of data.
*
* @param ArrayObject|null $data The array of data to initialize the context.
*/
public static function reset(?ArrayObject $data = null): void;
/**
* Destroy the context.
*/
public static function destroy(): void;
}

View File

@ -0,0 +1,107 @@
<?php
namespace Workerman\Coroutine\Context;
use ArrayObject;
use WeakMap;
use Fiber as BaseFiber;
/**
* Class Fiber
*/
class Fiber implements ContextInterface
{
/**
* @var WeakMap
*/
private static WeakMap $contexts;
/**
* @var ArrayObject
*/
private static ArrayObject $nonFiberContext;
/**
* @inheritDoc
*/
public static function get(?string $name = null, mixed $default = null): mixed
{
$fiber = BaseFiber::getCurrent();
if ($fiber === null) {
return $name !== null ? (static::$nonFiberContext[$name] ?? $default) : static::$nonFiberContext;
}
if ($name === null) {
return static::$contexts[$fiber] ??= new ArrayObject([], ArrayObject::ARRAY_AS_PROPS);
}
return static::$contexts[$fiber][$name] ?? $default;
}
/**
* @inheritDoc
*/
public static function set(string $name, $value): void
{
$fiber = BaseFiber::getCurrent();
if ($fiber === null) {
static::$nonFiberContext[$name] = $value;
return;
}
static::$contexts[$fiber] ??= new ArrayObject([], ArrayObject::ARRAY_AS_PROPS);
static::$contexts[$fiber][$name] = $value;
}
/**
* @inheritDoc
*/
public static function has(string $name): bool
{
$fiber = BaseFiber::getCurrent();
if ($fiber === null) {
return static::$nonFiberContext->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();

View File

@ -0,0 +1,63 @@
<?php
namespace Workerman\Coroutine\Context;
use ArrayObject;
use Swoole\Coroutine;
class Swoole implements ContextInterface
{
/**
* @inheritDoc
*/
public static function get(?string $name = null, mixed $default = null): mixed
{
$context = Coroutine::getContext();
if (!$context) {
return $default;
}
$context->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([]);
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace Workerman\Coroutine\Context;
use ArrayObject;
use Swow\Coroutine;
use WeakMap;
class Swow implements ContextInterface
{
/**
* @var WeakMap
*/
public static WeakMap $contexts;
/**
* @inheritDoc
*/
public static function get(?string $name = null, mixed $default = null): mixed
{
$fiber = Coroutine::getCurrent();
if ($name === null) {
static::$contexts[$fiber] ??= new ArrayObject([], ArrayObject::ARRAY_AS_PROPS);
return static::$contexts[$fiber];
}
return static::$contexts[$fiber][$name] ?? $default;
}
/**
* @inheritDoc
*/
public static function set(string $name, $value): void
{
$coroutine = Coroutine::getCurrent();
static::$contexts[$coroutine] ??= new ArrayObject([], ArrayObject::ARRAY_AS_PROPS);
static::$contexts[$coroutine][$name] = $value;
}
/**
* @inheritDoc
*/
public static function has(string $name): bool
{
$fiber = Coroutine::getCurrent();
return isset(static::$contexts[$fiber]) && static::$contexts[$fiber]->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();

View File

@ -0,0 +1,129 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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<CoroutineInterface>
*/
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();

View File

@ -0,0 +1,90 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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;
}

View File

@ -0,0 +1,154 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();

View File

@ -0,0 +1,148 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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]);
}
}

View File

@ -0,0 +1,91 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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();
}
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace Workerman\Coroutine\Exception;
class PoolException extends \RuntimeException
{
}

View File

@ -0,0 +1,66 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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");
}
}

View File

@ -0,0 +1,109 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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;
}
}

386
vendor/workerman/coroutine/src/Pool.php vendored Normal file
View File

@ -0,0 +1,386 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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);
}
}

View File

@ -0,0 +1,69 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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;
}

View File

@ -0,0 +1,67 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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;
}
}

View File

@ -0,0 +1,77 @@
<?php
/**
* @author workbunny/Chaz6chez
* @email chaz6chez1993@outlook.com
*/
declare(strict_types=1);
namespace Workerman\Coroutine;
use BadMethodCallException;
use Workerman\Worker;
use Workerman\Coroutine\Coroutine\CoroutineInterface;
use Workerman\Coroutine\WaitGroup\Fiber as FiberWaitGroup;
use Workerman\Coroutine\WaitGroup\Swoole as SwooleWaitGroup;
use Workerman\Coroutine\WaitGroup\Swow as SwowWaitGroup;
use Workerman\Coroutine\WaitGroup\WaitGroupInterface;
use Workerman\Events\Swoole;
use Workerman\Events\Swow;
/**
* @method bool add(int $delta = 1)
* @method bool done()
* @method int count()
* @method bool wait(int|float $timeout = -1)
*/
class WaitGroup
{
/**
* @var class-string<CoroutineInterface>
*/
protected static string $driverClass;
/**
* @var WaitGroupInterface
*/
protected WaitGroupInterface $driver;
/**
* 构造方法
*/
public function __construct()
{
$this->driver = new (self::driverClass());
}
/**
* Get driver class.
*
* @return class-string<CoroutineInterface>
*/
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);
}
}

View File

@ -0,0 +1,63 @@
<?php
/**
* @author workbunny/Chaz6chez
* @email chaz6chez1993@outlook.com
*/
declare(strict_types=1);
namespace Workerman\Coroutine\WaitGroup;
use Workerman\Coroutine\Channel\Fiber as Channel;
class Fiber implements WaitGroupInterface
{
/** @var int */
protected int $count;
/**
* @var Channel
*/
protected Channel $channel;
public function __construct()
{
$this->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;
}
}

View File

@ -0,0 +1,63 @@
<?php
/**
* @author workbunny/Chaz6chez
* @email chaz6chez1993@outlook.com
*/
declare(strict_types=1);
namespace Workerman\Coroutine\WaitGroup;
use Swoole\Coroutine\WaitGroup;
use Throwable;
/**
* Class Swoole
*/
class Swoole implements WaitGroupInterface
{
/** @var WaitGroup */
protected WaitGroup $waitGroup;
public function __construct()
{
$this->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;
}
}
}

View File

@ -0,0 +1,64 @@
<?php
/**
* @author workbunny/Chaz6chez
* @email chaz6chez1993@outlook.com
*/
declare(strict_types=1);
namespace Workerman\Coroutine\WaitGroup;
use Swow\Sync\WaitGroup;
use Throwable;
class Swow implements WaitGroupInterface
{
/** @var WaitGroup */
protected WaitGroup $waitGroup;
/** @var int count */
protected int $count;
public function __construct()
{
$this->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;
}
}
}

View File

@ -0,0 +1,42 @@
<?php
/**
* @author workbunny/Chaz6chez
* @email chaz6chez1993@outlook.com
*/
declare(strict_types=1);
namespace Workerman\Coroutine\WaitGroup;
interface WaitGroupInterface
{
/**
* Increment count
*
* @param int $delta
* @return bool
*/
public function add(int $delta = 1): bool;
/**
* Complete count
*
* @return bool
*/
public function done(): bool;
/**
* Return count
*
* @return int
*/
public function count(): int;
/**
* Wait
*
* @param int|float $timeout second
* @return bool timeout:false success:true
*/
public function wait(int|float $timeout = -1): bool;
}

View File

@ -0,0 +1,36 @@
<?php
namespace Swow;
class Coroutine
{
public function resume(mixed ...$args): mixed
{
// Stub for PHPStorm
return null;
}
public static function getCurrent(): static
{
// Stub for PHPStorm
return new Coroutine;
}
public static function yield (mixed ...$args) : mixed
{
// Stub for PHPStorm
return null;
}
public function getId() : int
{
// Stub for PHPStorm
return 0;
}
public static function run(callable $callable , mixed ... $args): static
{
// Stub for PHPStorm
return new Coroutine;
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace tests;
use PHPUnit\Framework\TestCase;
use Workerman\Coroutine\Barrier;
use Workerman\Coroutine;
use Workerman\Timer;
/**
* Class FiberBarrierTest
*
* Tests for the Fiber Barrier implementation.
*/
class BarrierTest extends TestCase
{
/**
* Test that the barrier is set to null after calling wait.
*/
public function testWaitSetsBarrierToNull()
{
$barrier = Barrier::create();
$results = [0];
Coroutine::create(function () use ($barrier, &$results) {
Timer::sleep(0.1);
$results[] = 1;
});
Coroutine::create(function () use ($barrier, &$results) {
Timer::sleep(0.2);
$results[] = 2;
});
Coroutine::create(function () use ($barrier, &$results) {
Timer::sleep(0.3);
$results[] = 3;
});
Barrier::wait($barrier);
$this->assertNull($barrier, 'Barrier should be null after wait is called.');
$this->assertEquals([0, 1, 2, 3], $results, 'All coroutines should have been executed.');
}
}

View File

@ -0,0 +1,313 @@
<?php
declare(strict_types=1);
namespace tests;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionException;
use stdClass;
use Workerman\Coroutine\Channel;
use PHPUnit\Framework\Attributes\DataProvider;
use Workerman\Coroutine\Channel\Memory;
use Workerman\Coroutine;
class ChannelTest extends TestCase
{
/**
* Test initializing channel with valid capacity.
*/
public function testInitializeWithValidCapacity()
{
$channel = new Channel(1);
$this->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();
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace tests;
use ArrayObject;
use PHPUnit\Framework\TestCase;
use Workerman\Coroutine\Context;
use Workerman\Coroutine;
// Now, the test cases
class ContextTest extends TestCase
{
public function testContextSetAndGetWithinCoroutine()
{
Coroutine::create(function () {
$key = 'testContextSetAndGetWithinCoroutine';
Context::set($key, 'value');
$this->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();
}
}

View File

@ -0,0 +1,184 @@
<?php
namespace tests;
use PHPUnit\Framework\TestCase;
use Workerman\Coroutine;
use Workerman\Coroutine\Coroutine\CoroutineInterface;
use Workerman\Events\Swoole;
use Workerman\Worker;
class CoroutineTest extends TestCase
{
public function testCreateReturnsCoroutineInterface()
{
$callable = function() {};
$coroutine = Coroutine::create($callable);
$this->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);
}
}

View File

@ -0,0 +1,346 @@
<?php
use PHPUnit\Framework\TestCase;
use Workerman\Coroutine\Channel\Fiber as Channel;
use Workerman\Timer;
use Fiber as BaseFiber;
class FiberChannelTest extends TestCase
{
/**
* Test basic push and pop operations.
*/
public function testBasicPushPop()
{
$channel = new Channel();
$fiber = new BaseFiber(function() use ($channel) {
$channel->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);
}
}

View File

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace tests;
use PHPUnit\Framework\TestCase;
use Workerman\Coroutine\Locker;
use RuntimeException;
use Workerman\Coroutine;
use ReflectionClass;
use Workerman\Timer;
class LockerTest extends TestCase
{
public function testLock()
{
$key = 'testLock';
Locker::lock($key);
$timeStart = microtime(true);
$timeDiff2 = 0;
Coroutine::create(function () use ($key, $timeStart, &$timeDiff2) {
$this->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();
}
}

View File

@ -0,0 +1,302 @@
<?php
namespace tests;
use PHPUnit\Framework\TestCase;
use Workerman\Coroutine\Parallel;
use Workerman\Coroutine;
use Workerman\Timer;
/**
* Test cases for the Workerman\Coroutine\Parallel class.
*/
class ParallelTest extends TestCase
{
/**
* Test that callables are added and executed, and results are collected properly.
*/
public function testAddAndWait()
{
$parallel = new Parallel();
$parallel->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);
}
}

View File

@ -0,0 +1,394 @@
<?php
namespace test;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use ReflectionProperty;
use Workerman\Coroutine;
use Workerman\Coroutine\Exception\PoolException;
use Workerman\Coroutine\Pool;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use stdClass;
use Exception;
use Workerman\Events\Event;
use Workerman\Events\Select;
use Workerman\Timer;
use Workerman\Worker;
class PoolTest extends TestCase
{
public function testConstructorWithConfig()
{
$config = [
'min_connections' => 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()
{
// 模拟关闭连接
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace tests;
use PHPUnit\Framework\TestCase;
use Workerman\Coroutine;
use Workerman\Timer;
use Workerman\Coroutine\WaitGroup;
/**
* Class WaitGroupTest
*
* Tests for the Fiber WaitGroup implementation.
*/
class WaitGroupTest extends TestCase
{
public function testWaitWaitGroupDone()
{
$waitGroup = new WaitGroup();
$this->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.');
}
}

View File

@ -0,0 +1,109 @@
<?php
use Workerman\Events\Event;
use Workerman\Events\Select;
use Workerman\Events\Swow;
use Workerman\Events\Swoole;
use Workerman\Events\Fiber;
use Workerman\Timer;
use Workerman\Worker;
require_once __DIR__ . '/../vendor/autoload.php';
$phpunitDisplayOptions = [
'--colors=always',
'--display-deprecations',
'--display-phpunit-deprecations',
'--display-errors',
'--display-notices',
'--display-warnings',
'--display-incomplete',
'--display-skipped',
];
if (DIRECTORY_SEPARATOR === '/' || (!extension_loaded('swow') && !class_exists(Revolt\EventLoop::class))) {
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',
]);
}, 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();

View File

@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2009-2025 walkor<walkor@workerman.net> 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.

477
vendor/workerman/workerman/README.md vendored Normal file
View File

@ -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
<?php
use Workerman\Worker;
require_once __DIR__ . '/vendor/autoload.php';
// Create a Websocket server
$ws_worker = new Worker('websocket://0.0.0.0:2346');
// Emitted when new connection come
$ws_worker->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
<?php
use Workerman\Worker;
require_once __DIR__ . '/vendor/autoload.php';
// SSL context.
$context = [
'ssl' => [
'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
<?php
use Workerman\Connection\TcpConnection;
use Workerman\Coroutine;
use Workerman\Events\Swoole;
use Workerman\Protocols\Http\Request;
use Workerman\Worker;
require_once __DIR__ . '/vendor/autoload.php';
$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) {
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
<?php
use Workerman\Connection\TcpConnection;
use Workerman\Coroutine\Barrier;
use Workerman\Coroutine;
use Workerman\Events\Swoole;
use Workerman\Protocols\Http\Request;
use Workerman\Worker;
require_once __DIR__ . '/vendor/autoload.php';
// 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) {
$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
<?php
use Workerman\Connection\TcpConnection;
use Workerman\Coroutine\Parallel;
use Workerman\Events\Swoole;
use Workerman\Protocols\Http\Request;
use Workerman\Worker;
require_once __DIR__ . '/vendor/autoload.php';
// 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) {
$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
<?php
use Workerman\Connection\TcpConnection;
use Workerman\Coroutine\Channel;
use Workerman\Coroutine;
use Workerman\Events\Swoole;
use Workerman\Protocols\Http\Request;
use Workerman\Worker;
require_once __DIR__ . '/vendor/autoload.php';
// 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) {
$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
<?php
use Workerman\Connection\TcpConnection;
use Workerman\Coroutine\Pool;
use Workerman\Events\Swoole;
use Workerman\Protocols\Http\Request;
use Workerman\Worker;
require_once __DIR__ . '/vendor/autoload.php';
class RedisPool
{
private Pool $pool;
public function __construct($host, $port, $max_connections = 10)
{
$pool = new Pool($max_connections);
$pool->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
<?php
use Workerman\Connection\TcpConnection;
use Workerman\Coroutine\Context;
use Workerman\Coroutine;
use Workerman\Coroutine\Pool;
use Workerman\Events\Swoole;
use Workerman\Protocols\Http\Request;
use Workerman\Worker;
require_once __DIR__ . '/vendor/autoload.php';
class Db
{
private static ?Pool $pool = null;
public static function __callStatic($name, $arguments)
{
if (self::$pool === null) {
self::initializePool();
}
// Get the connection from the coroutine context
// to ensure the same connection is used within the same coroutine
$pdo = Context::get('pdo');
if (!$pdo) {
// If no connection is retrieved, get one from the connection pool
$pdo = self::$pool->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
<a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=UQGGS9UB35WWG">PayPal</a>
## LICENSE
Workerman is released under the [MIT license](https://github.com/walkor/workerman/blob/master/MIT-LICENSE.txt).

View File

@ -0,0 +1,6 @@
# Security Policy
## Reporting a Vulnerability
Please contact by email walkor@workerman.net

View File

@ -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": "<v1.0.0"
},
"require-dev": {
"pestphp/pest": "^2.36 || ^3 || ^4",
"mockery/mockery": "^1.6",
"guzzlehttp/guzzle": "^7.10",
"phpstan/phpstan": "^2.1"
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"scripts": {
"analyze": "php -d memory_limit=1G vendor/phpstan/phpstan/phpstan.phar",
"test": "pest --colors=always"
}
}

View File

@ -0,0 +1,478 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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<string, string>
*/
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;
}
}
}
}

View File

@ -0,0 +1,222 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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);
}
}
}
}

View File

@ -0,0 +1,187 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,246 @@
<?php
/**
* This file is part of workerman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @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(),
];
}
}

Some files were not shown because too many files have changed in this diff Show More