Skip to content

Commit 853f7bd

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents b022ecb + 554d8f3 commit 853f7bd

2 files changed

Lines changed: 193 additions & 0 deletions

File tree

src/http_server_class.c

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,10 @@ struct http_server_object {
387387
bool running;
388388
bool stopping;
389389
bool listeners_paused;
390+
/* Set once start()'s post-wakeup drain has emptied server_scope, so the
391+
* http_server_free fallback drain is skipped on the normal stop() path
392+
* (issue #74). */
393+
bool scope_drained;
390394
/* Set in transfer_obj LOAD when this object was constructed by the
391395
* built-in worker pool (issue #11) — start() skips re-spawning the
392396
* pool and runs the standalone event loop. */
@@ -1486,6 +1490,112 @@ static zend_async_event_t *create_server_wait_event(void)
14861490
return event;
14871491
}
14881492

1493+
/* Build an Async\Timeout awaitable for `ms` — the cancellation token
1494+
* Scope::awaitCompletion() expects. Returns false (out left UNDEF) on failure. */
1495+
static bool http_server_make_timeout(const zend_ulong ms, zval *out)
1496+
{
1497+
zval fname, arg;
1498+
ZVAL_STRINGL(&fname, "Async\\timeout", sizeof("Async\\timeout") - 1);
1499+
ZVAL_LONG(&arg, (zend_long) ms);
1500+
ZVAL_UNDEF(out);
1501+
1502+
const bool ok = call_user_function(NULL, NULL, &fname, out, 1, &arg) == SUCCESS
1503+
&& EG(exception) == NULL
1504+
&& Z_TYPE_P(out) == IS_OBJECT;
1505+
1506+
zval_ptr_dtor(&fname);
1507+
1508+
if (false == ok) {
1509+
zval_ptr_dtor(out);
1510+
ZVAL_UNDEF(out);
1511+
1512+
if (EG(exception)) {
1513+
zend_clear_exception();
1514+
}
1515+
}
1516+
1517+
return ok;
1518+
}
1519+
1520+
/* Async\Scope::isFinished() on the live scope object. */
1521+
static bool http_server_scope_is_finished(zend_object *scope_object)
1522+
{
1523+
zval retval;
1524+
ZVAL_UNDEF(&retval);
1525+
zend_call_method_with_0_params(scope_object, scope_object->ce, NULL, "isFinished", &retval);
1526+
1527+
const bool finished = (Z_TYPE(retval) == IS_TRUE);
1528+
zval_ptr_dtor(&retval);
1529+
return finished;
1530+
}
1531+
1532+
/* Call a no-arg, void Scope method, swallowing any exception — a cancellation
1533+
* thrown during shutdown is expected, not an error. */
1534+
static void http_server_scope_call(zend_object *scope_object, const char *method)
1535+
{
1536+
zval retval;
1537+
ZVAL_UNDEF(&retval);
1538+
zend_call_method_with_0_params(scope_object, scope_object->ce, NULL, method, &retval);
1539+
zval_ptr_dtor(&retval);
1540+
1541+
if (EG(exception)) {
1542+
zend_clear_exception();
1543+
}
1544+
}
1545+
1546+
/* Graceful-shutdown drain (issue #74): empty server_scope before it is
1547+
* disposed. Runs on the start() coroutine after stop() wakes it — not inside
1548+
* stop(), which may be called from a handler that lives in server_scope. Uses
1549+
* the Scope's own API (awaitCompletion within the grace window, then cancel +
1550+
* awaitAfterCancellation); these own the scope lifetime, so nothing dangles. */
1551+
static void http_server_drain_scope(http_server_object *server)
1552+
{
1553+
if (server->scope_object == NULL || server->server_scope == NULL || server->scope_drained) {
1554+
return;
1555+
}
1556+
1557+
/* Need a real coroutine to suspend on. In scheduler/teardown context we
1558+
* cannot await — leave scope_drained unset so a later call may retry. */
1559+
if (ZEND_ASYNC_CURRENT_COROUTINE == NULL || ZEND_ASYNC_IS_SCHEDULER_CONTEXT
1560+
|| false == ZEND_ASYNC_ON) {
1561+
return;
1562+
}
1563+
1564+
zend_object *scope_object = server->scope_object;
1565+
GC_ADDREF(scope_object); /* hold the Scope object across the method calls */
1566+
1567+
/* Phase 1: let handlers finish on their own, bounded by the grace window. */
1568+
const zend_ulong grace_ms = (zend_ulong) server->shutdown_timeout_s * 1000u;
1569+
1570+
if (grace_ms > 0 && false == http_server_scope_is_finished(scope_object)) {
1571+
zval timeout;
1572+
1573+
if (http_server_make_timeout(grace_ms, &timeout)) {
1574+
zval retval;
1575+
ZVAL_UNDEF(&retval);
1576+
zend_call_method_with_1_params(scope_object, scope_object->ce, NULL,
1577+
"awaitCompletion", &retval, &timeout);
1578+
zval_ptr_dtor(&retval);
1579+
zval_ptr_dtor(&timeout);
1580+
1581+
if (EG(exception)) {
1582+
zend_clear_exception(); /* grace expired, or completed during the wait */
1583+
}
1584+
}
1585+
}
1586+
1587+
/* Phase 2: cancel whatever is still parked, then await the cancellation.
1588+
* server_scope is non-dispose-safely (see start()), so cancel() hard-
1589+
* terminates the handlers rather than zombifying them. */
1590+
if (false == http_server_scope_is_finished(scope_object)) {
1591+
http_server_scope_call(scope_object, "cancel");
1592+
http_server_scope_call(scope_object, "awaitAfterCancellation");
1593+
}
1594+
1595+
server->scope_drained = true;
1596+
OBJ_RELEASE(scope_object);
1597+
}
1598+
14891599
/* Accept callback — fired once per accepted connection by the reactor.
14901600
* `result` points to the accepted client socket fd (zend_socket_t) the
14911601
* reactor extracted from libuv; `exception` is set on accept failures.
@@ -2304,6 +2414,13 @@ ZEND_METHOD(TrueAsync_HttpServer, start)
23042414
"Failed to create server scope", 0);
23052415
RETURN_FALSE;
23062416
}
2417+
2418+
/* Hard-terminate handler coroutines at shutdown instead of zombifying them:
2419+
* clear DISPOSE_SAFELY (inherited from the main scope), else cancel() leaves
2420+
* zombies and the shutdown drain's awaitAfterCancellation waits forever
2421+
* (issue #74). Child request scopes inherit this non-safe mode. */
2422+
ZEND_ASYNC_SCOPE_CLR_DISPOSE_SAFELY(server->server_scope);
2423+
23072424
/* Keep our own pointer to the scope's zend_object. scope_destroy (the
23082425
* dtor_obj handler) runs during request shutdown's dtor phase BEFORE
23092426
* our http_server_free (the free_obj handler) runs — and it nulls
@@ -2748,6 +2865,11 @@ ZEND_METHOD(TrueAsync_HttpServer, start)
27482865
zend_bailout();
27492866
}
27502867

2868+
/* Drain in-flight per-request handler coroutines now, while we are still
2869+
* on the start() coroutine and can suspend, so server_scope is empty when
2870+
* the object is freed (issue #74). On bailout we already longjmp'd above. */
2871+
http_server_drain_scope(server);
2872+
27512873
RETURN_TRUE;
27522874
}
27532875
/* }}} */
@@ -3347,6 +3469,11 @@ static void http_server_free(zend_object *obj)
33473469
}
33483470
}
33493471

3472+
/* Fallback drain (issue #74): if start()'s drain never ran (freed mid-flight,
3473+
* or stop() never called) and we can still suspend, empty the scope here.
3474+
* No-op in teardown/scheduler context — the OBJ_RELEASE below cancels the rest. */
3475+
http_server_drain_scope(server);
3476+
33503477
/* Release the scope_object we took at scope creation in start().
33513478
*
33523479
* scope_destroy (the object dtor) will clear scope->scope_object = NULL
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
--TEST--
2+
HttpServer: graceful shutdown drains in-flight per-request coroutines (issue #74)
3+
--EXTENSIONS--
4+
true_async_server
5+
true_async
6+
--FILE--
7+
<?php
8+
// A handler suspended in a per-request child scope must not be left in flight
9+
// when the server scope is disposed at shutdown. With setShutdownTimeout(0)
10+
// the still-parked /park handler is force-cancelled; a /work handler that
11+
// finishes inside the grace window completes normally. Without the drain the
12+
// process would hang on the parked coroutine (or abort in a debug build).
13+
use TrueAsync\HttpServer;
14+
use TrueAsync\HttpServerConfig;
15+
use function Async\spawn;
16+
use function Async\await;
17+
18+
$port = 20180 + getmypid() % 40;
19+
20+
$config = (new HttpServerConfig())
21+
->addListener('127.0.0.1', $port)
22+
->setReadTimeout(10)
23+
->setWriteTimeout(10)
24+
->setShutdownTimeout(0); // no grace: force-cancel whatever is parked
25+
26+
$server = new HttpServer($config);
27+
28+
$server->addHttpHandler(function ($request, $response) use ($server) {
29+
$uri = $request->getUri();
30+
if (str_starts_with($uri, '/park')) {
31+
Async\request_context()->set('rid', ltrim($uri, '/'));
32+
Async\delay(60000); // stays parked across shutdown
33+
$response->setStatusCode(200)->setBody("late\n")->end();
34+
return;
35+
}
36+
$response->setStatusCode(200)->setBody("stopping\n")->end();
37+
$server->stop();
38+
});
39+
40+
$client = spawn(function () use ($port) {
41+
usleep(40000);
42+
// Fire the parked request first (do not wait for its response).
43+
$park = @stream_socket_client("tcp://127.0.0.1:$port", $e, $s, 2);
44+
if ($park) {
45+
fwrite($park, "GET /park HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
46+
}
47+
usleep(40000);
48+
// Now stop the server while /park is still suspended.
49+
$stop = @stream_socket_client("tcp://127.0.0.1:$port", $e, $s, 2);
50+
if ($stop) {
51+
fwrite($stop, "GET /stop HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
52+
$buf = '';
53+
while (!feof($stop)) { $buf .= fread($stop, 8192); }
54+
fclose($stop);
55+
echo str_contains($buf, "stopping") ? "stop responded\n" : "stop missing\n";
56+
}
57+
if ($park) { @fclose($park); }
58+
});
59+
60+
$server->start();
61+
await($client);
62+
echo "server stopped cleanly\n";
63+
?>
64+
--EXPECT--
65+
stop responded
66+
server stopped cleanly

0 commit comments

Comments
 (0)