Skip to content

Commit fed4a24

Browse files
authored
Merge pull request #737 from EdmondDantes/true-async
[true-async] Add true-async-server framework
2 parents 37ae979 + f8814bc commit fed4a24

64 files changed

Lines changed: 1675 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
FROM trueasync/php-true-async:0.7.0-beta.3-php8.6
2+
3+
RUN printf '%s\n' \
4+
'opcache.jit=1255' \
5+
'opcache.jit_buffer_size=128M' \
6+
'opcache.memory_consumption=256' \
7+
'opcache.max_accelerated_files=10000' \
8+
'opcache.validate_timestamps=0' \
9+
'memory_limit=1024M' \
10+
> /etc/php.d/99-arena.ini \
11+
&& rm -f /etc/php.d/xdebug.ini
12+
13+
WORKDIR /app
14+
COPY entry.php PostgreSQL.php SQLite.php /app/
15+
16+
EXPOSE 8080 8443
17+
18+
CMD ["php", "/app/entry.php"]
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* Postgres access for TrueAsync HTTP handlers.
7+
*
8+
* Uses the native PDO connection pool shipped with ext-async (PDO::ATTR_POOL_*)
9+
* — each PDO method call grabs an idle connection, runs, and returns it to the
10+
* pool. Coroutines that find the pool exhausted park on the libuv reactor
11+
* instead of blocking the worker thread.
12+
*/
13+
final class PostgreSQL
14+
{
15+
private static ?PDO $pdo = null;
16+
private static bool $available = false;
17+
private const SQL =
18+
'SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count '
19+
. 'FROM items WHERE price BETWEEN ? AND ? LIMIT ?';
20+
private const FORTUNES_SQL = 'SELECT id, message FROM fortune';
21+
22+
public static function init(): void
23+
{
24+
$url = getenv('DATABASE_URL') ?: '';
25+
if ($url === '') {
26+
return;
27+
}
28+
29+
$parts = parse_url($url);
30+
$dsn = sprintf(
31+
'pgsql:host=%s;port=%s;dbname=%s',
32+
$parts['host'] ?? 'localhost',
33+
$parts['port'] ?? 5432,
34+
ltrim($parts['path'] ?? '/benchmark', '/')
35+
);
36+
37+
// PG sweet spot is ~4×CPU backends; more = lock/context contention.
38+
// Cap total at min(DATABASE_MAX_CONN, 4×CPU), split per worker.
39+
$cpus = \Async\available_parallelism();
40+
$workers = max(1, (int)(getenv('WORKERS') ?: $cpus));
41+
$envCap = (int)(getenv('DATABASE_MAX_CONN') ?: 4 * $cpus);
42+
$totalMax = min($envCap, 4 * $cpus);
43+
$maxConn = max(2, intdiv($totalMax, $workers));
44+
$minConn = (int)(getenv('DATABASE_MIN_CONN') ?: max(1, intdiv($maxConn, 2)));
45+
46+
self::$pdo = new PDO(
47+
$dsn,
48+
$parts['user'] ?? 'bench',
49+
$parts['pass'] ?? 'bench',
50+
[
51+
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
52+
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
53+
PDO::ATTR_EMULATE_PREPARES => false,
54+
PDO::ATTR_POOL_ENABLED => true,
55+
PDO::ATTR_POOL_MIN => $minConn,
56+
PDO::ATTR_POOL_MAX => $maxConn,
57+
PDO::ATTR_POOL_STMT_CACHE_SIZE => 32,
58+
]
59+
);
60+
61+
self::$available = true;
62+
}
63+
64+
public static function query(float $min, float $max, int $limit = 50): string
65+
{
66+
if (!self::$available) {
67+
self::init();
68+
if (!self::$available) {
69+
return '{"items":[],"count":0}';
70+
}
71+
}
72+
73+
try {
74+
$stmt = self::$pdo->prepare(self::SQL);
75+
$stmt->execute([$min, $max, $limit]);
76+
$rows = [];
77+
while ($row = $stmt->fetch()) {
78+
$rows[] = [
79+
'id' => $row['id'],
80+
'name' => $row['name'],
81+
'category' => $row['category'],
82+
'price' => $row['price'],
83+
'quantity' => $row['quantity'],
84+
'active' => (bool)$row['active'],
85+
'tags' => json_decode($row['tags'], true),
86+
'rating' => [
87+
'score' => $row['rating_score'],
88+
'count' => $row['rating_count'],
89+
],
90+
];
91+
}
92+
return json_encode(
93+
['items' => $rows, 'count' => count($rows)],
94+
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
95+
);
96+
} catch (\Throwable) {
97+
return '{"items":[],"count":0}';
98+
}
99+
}
100+
101+
/**
102+
* @return list<array{id:int,message:string}>
103+
*/
104+
public static function fortunes(): array
105+
{
106+
if (!self::$available) {
107+
self::init();
108+
if (!self::$available) {
109+
return [];
110+
}
111+
}
112+
113+
try {
114+
$stmt = self::$pdo->prepare(self::FORTUNES_SQL);
115+
$stmt->execute();
116+
$rows = [];
117+
while ($row = $stmt->fetch()) {
118+
$rows[] = ['id' => (int)$row['id'], 'message' => (string)$row['message']];
119+
}
120+
return $rows;
121+
} catch (\Throwable) {
122+
return [];
123+
}
124+
}
125+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# true-async-server
2+
3+
[TrueAsync Server](https://github.com/true-async/server) — a native PHP
4+
extension that runs an HTTP/1.1 + HTTP/2 + HTTP/3 server inside the PHP
5+
process. No FastCGI, no separate Caddy / FrankenPHP / nginx in front.
6+
Everything (accept, parse, dispatch to PHP handler, response) happens on
7+
the TrueAsync coroutine event loop in the same OS thread that owns the
8+
connection.
9+
10+
- **Source:** <https://github.com/true-async/server>
11+
- **Engine:** TrueAsync (PHP fork — <https://github.com/true-async/php-src>)
12+
- **Tier:** `tuned`
13+
- **Image:** `trueasync/php-true-async:0.7.0-alpha.13-php8.6`
14+
15+
### Related repositories
16+
17+
| Repo | Purpose |
18+
|------|---------|
19+
| [`true-async/server`](https://github.com/true-async/server) | This extension — the HTTP/1+2+3 server itself (source of `true_async_server.so`) |
20+
| [`true-async/php-src`](https://github.com/true-async/php-src) | PHP 8.6 fork with the TrueAsync coroutine API in core |
21+
| [`true-async/php-async`](https://github.com/true-async/php-async) | `ext/async` — coroutines, `ThreadPool`, `spawn`, PDO connection pool |
22+
| [`true-async/releases`](https://github.com/true-async/releases) | Release pipeline: builds Docker images and Windows ZIPs from the three repos above |
23+
| [`true-async/frankenphp`](https://github.com/true-async/frankenphp) | TrueAsync fork of FrankenPHP (separate framework entry, not used here) |
24+
| [`true-async/xdebug`](https://github.com/true-async/xdebug) | Xdebug fork patched for the TrueAsync runtime |
25+
| [Docker Hub `trueasync/php-true-async`](https://hub.docker.com/r/trueasync/php-true-async) | Pre-built images consumed by this framework's `Dockerfile` |
26+
27+
## How it works
28+
29+
### One process, N event-loop threads
30+
31+
A single PHP process is launched. The main thread reads the dataset
32+
into shared read-only memory, constructs an `HttpServer` from
33+
`HttpServerConfig`, mounts a `StaticHandler` for `/static/`, and
34+
registers a single PHP callback via `addHttpHandler`. Static files
35+
themselves are served from C — the PHP callback never sees them.
36+
37+
The main thread then submits an `Async\ThreadPool` job for each CPU
38+
(`N = available_parallelism()`, overridable via `WORKERS=…`). The job
39+
body just calls `$server->start()` on a thread-transferred copy of the
40+
server object. The transfer copies the registered callbacks and the
41+
listener configuration — there is no shared mutable state between
42+
threads.
43+
44+
Each thread runs its own libuv event loop. There is no master/worker
45+
split: every thread accepts, parses, executes the handler, writes the
46+
response, and recycles the connection. `SO_REUSEPORT` lets all
47+
threads bind the same TCP/UDP ports — the kernel hashes incoming SYNs
48+
across the listening sockets so connections distribute evenly.
49+
50+
### Protocol detection happens once per connection
51+
52+
When bytes first arrive on a plain-TCP listener, a small detector
53+
inspects the first 8+ bytes:
54+
55+
- starts with `PRI ` → HTTP/2 cleartext (h2c) — route into nghttp2.
56+
- starts with an HTTP/1.1 method byte (`G`, `P`, `D`, `H`, `O`, `C`, `T`)
57+
→ HTTP/1.1 — route into the llhttp parser.
58+
- otherwise after 24 bytes → reject as `400 Bad Request` (or h2
59+
`BAD_CLIENT_MAGIC`).
60+
61+
For TLS listeners, ALPN does the work during the handshake — the server
62+
advertises `[h2, http/1.1]` and the client picks. ALPN result decides
63+
which strategy is installed; no first-byte sniff happens after the TLS
64+
handshake.
65+
66+
For UDP listeners (HTTP/3), packets go directly to the QUIC stack;
67+
ALPN inside the QUIC TLS handshake selects `h3` or fails.
68+
69+
Once the strategy is installed it stays for the lifetime of the
70+
connection — no per-request re-detection.
71+
72+
### Coroutine per request, not per connection
73+
74+
The accept loop pulls one connection. The chosen protocol strategy reads
75+
bytes off the socket and assembles requests:
76+
77+
- HTTP/1.1 — one request at a time, possibly pipelined; for each parsed
78+
request a fresh PHP coroutine is spawned and given `(HttpRequest,
79+
HttpResponse)`.
80+
- HTTP/2 / HTTP/3 — every stream is its own request; each opened stream
81+
spawns a coroutine. Streams on the same connection run truly in
82+
parallel within the event loop, multiplexed onto the wire by nghttp2 /
83+
nghttp3.
84+
85+
The coroutine runs the user callback. When the callback awaits I/O
86+
(database, file, sleep), the coroutine yields back to the event loop,
87+
which immediately serves another stream / request. There is no
88+
`pthread_create` per request and no thread pool dispatch; coroutines are
89+
stack-switched in userland.
90+
91+
When the callback returns, `HttpResponse` is committed to the wire
92+
(buffered or streamed depending on whether the handler called `send()`),
93+
the coroutine is disposed, and its arena (`conn_arena`) is reset for the
94+
next request on the same connection.
95+
96+
### Bailout firewall
97+
98+
If the user callback hits a fatal (E_ERROR, OOM, exception during
99+
shutdown) and triggers `zend_bailout`, the protocol strategy catches it
100+
at the request boundary:
101+
102+
- emits a 500 on the failing request,
103+
- logs the PHP cause via SAPI's error pipeline,
104+
- on glibc, dumps the C-level stack via `backtrace(3)` for postmortem,
105+
- keeps the listener and other in-flight requests alive.
106+
107+
This is what makes a single-process server safe to run user PHP code
108+
that may legitimately fatal — one bad handler doesn't take the listener
109+
down.
110+
111+
### Compression pipeline
112+
113+
The response writer transparently compresses bodies that opt in
114+
(`HttpResponse` does not call `setNoCompression()`, MIME is on the
115+
whitelist, body ≥ 1 KiB threshold) when the client's `Accept-Encoding`
116+
allows it. Negotiation is RFC 9110 §12.5.3 (q-values, `identity;q=0`,
117+
`*;q=0`). Encoding runs on streamed chunks, not buffered, so chunked H1
118+
and H2 DATA frames stay efficient. Inbound `Content-Encoding: gzip`
119+
request bodies are decoded transparently with an anti-zip-bomb cap. The
120+
encoder is zlib-ng when available, system zlib otherwise.
121+
122+
`entry.php` enables this middleware via
123+
`HttpServerConfig::setCompressionEnabled(true)`, so the `/json/*`
124+
responses are transparently compressed when the client advertises
125+
`Accept-Encoding: br|gzip` — that's what powers the `json-comp`
126+
profile.
127+
128+
### What `entry.php` actually contains
129+
130+
A `StaticHandler` mount for `/static/` plus a flat PHP dispatcher:
131+
132+
```php
133+
$server->addStaticHandler(
134+
(new StaticHandler('/static/', '/data/static'))
135+
->enablePrecompressed('br', 'gzip')
136+
->setEtagEnabled(true)
137+
->setOpenFileCache(1024, 60)
138+
);
139+
140+
$server->addHttpHandler(static function ($req, $res) use ($dataset, $datasetCount) {
141+
$path = $req->getPath();
142+
143+
if ($path === '/baseline11' || $path === '/baseline2') { ... sum ... }
144+
if ($path === '/pipeline') { ... 'ok' ... }
145+
if (str_starts_with($path, '/json/')) { ... slice + json_encode ... }
146+
if ($path === '/upload') { ... awaitBody ... }
147+
/* /static/* is served by StaticHandler above; anything else → 404 */
148+
});
149+
```
150+
151+
Order is by request frequency under the validation suite; `/baseline11`
152+
goes first because it's the hottest endpoint across `baseline`,
153+
`pipelined`, and `limited-conn` profiles.
154+
155+
## Listeners (in `entry.php`)
156+
157+
| Port | Protocol | Used by profile |
158+
|------|----------|----------------|
159+
| 8080 | h1 cleartext | `baseline`, `pipelined`, `limited-conn`, `json`, `upload` |
160+
| 8081 | h1 + TLS | `json-tls` |
161+
| 8443 | h1 + h2 + TLS (ALPN) | `baseline-h2` |
162+
163+
## Subscribed profiles
164+
165+
```
166+
baseline, pipelined, limited-conn, json, json-comp, json-tls,
167+
upload, static, static-h2, baseline-h2
168+
```
169+
170+
All ten pass the HttpArena validation suite (39/39 checks) on the
171+
published image.
172+
173+
`static` / `static-h2` are served by the server's built-in C
174+
`StaticHandler` (`addStaticHandler` in `entry.php`), which does
175+
sendfile + per-request precompressed sidecar (`.br` / `.gz`) selection
176+
and an open-file cache — no PHP-level buffering.
177+
178+
`json-comp` uses the server's transparent compression middleware
179+
(`setCompressionEnabled(true)` on the config), which negotiates
180+
brotli / gzip from `Accept-Encoding` automatically.
181+
182+
## Not yet subscribed (work-in-progress)
183+
184+
- `baseline-h2c`, `json-h2c` — HttpArena requires port 8082 to refuse
185+
HTTP/1.1, but `protocol_mask` in TrueAsync Server is currently
186+
per-server, not per-listener. Per-listener mask is on the roadmap.
187+
- `async-db`, `crud`, `api-4`, `api-16`, `fortunes` — DB-backed; we ship
188+
a PostgreSQL adapter (`PostgreSQL.php` via `pdo-async` connection
189+
pool) but haven't validated the full suite yet.
190+
- `baseline-h3`, `static-h3`, `gateway-h3` — HTTP/3 listener
191+
(`addHttp3Listener`) is in the server but not yet enabled in
192+
`entry.php`.
193+
194+
The full feature roadmap lives in
195+
[`FUTURES.md`](https://github.com/true-async/server/blob/main/FUTURES.md)
196+
on the server repo.
197+
198+
## Running locally
199+
200+
```bash
201+
./scripts/validate.sh true-async-server
202+
./scripts/benchmark.sh true-async-server baseline-h2
203+
./scripts/benchmark-lite.sh true-async-server baseline-h2
204+
```
205+
206+
`benchmark-lite.sh` defaults `H2THREADS=nproc/2` so it's friendly to
207+
laptops; `benchmark.sh` is the leaderboard configuration (64 threads on
208+
dedicated hardware).
209+
210+
## Local development build
211+
212+
`build.sh` and `Dockerfile.local` exist for testing un-tagged commits of
213+
`true-async/server` against this framework: they copy a host-built
214+
`php` binary and `true_async_server.so` over the upstream alpha image.
215+
Not used in CI.
216+
217+
## Maintainers
218+
219+
- [@EdmondDantes](https://github.com/EdmondDantes)

0 commit comments

Comments
 (0)