Summary
When HttpServer is torn down (its PHP object freed) while a per-request handler coroutine is still in-flight / suspended, the server scope is disposed while a child per-request scope still holds that coroutine. In a debug build this aborts with:
scope->coroutines.length == 0 && "Scope should be empty before disposal"
at ext/async/scope.c:1189
The request_scope feature itself works correctly for normal request handling (per-request isolation + subtree inheritance, verified on H1/H2/H3). This is a graceful-shutdown gap, surfaced by the per-request child scopes that the feature introduces.
Reproduction
A handler that suspends (e.g. Async\delay) is still parked when the server is stopped/destroyed:
$server->addHttpHandler(function ($req, $resp) use (&$done, $server) {
Async\request_context()->set('rid', ltrim($req->getUri(), '/'));
Async\delay(60); // handler is suspended here
$resp->setStatusCode(200)->setBody("ok\n"); $resp->end();
if (++$done >= 2) $server->stop();
});
// two concurrent connections fired so one handler is still parked at stop()
Result: abort at ext/async/scope.c:1189 (debug build). Without the per-request scope (handlers spawned directly in server_scope) the abort does not occur.
Root cause (call chain)
http_server_free() releases the last ref on the server-scope zend object:
src/core/http_server_class.c:3352 — OBJ_RELEASE(scope_object);
- The scope object dtor clears
scope_object and calls try_to_dispose → scope_dispose(server_scope).
- A still-parked handler coroutine being cancelled finalizes with a cancellation exception and gives its scope a "last chance to catch":
ext/async/coroutine.c:707 — ZEND_ASYNC_SCOPE_CATCH(coroutine->coroutine.scope, ...).
scope_catch_or_cancel propagates up the hierarchy:
ext/async/scope.c:1047 — return parent_scope->catch_or_cancel(...).
- The parent notifies its listeners, which runs
scope_dispose:
ext/async/scope.c:1029 — ZEND_ASYNC_CALLBACKS_NOTIFY(&async_scope->scope.event, ...).
scope_dispose cascades into child scopes:
ext/async/scope.c:1196 — child_scope->scope.event.dispose(...).
- The child per-request scope still holds the not-yet-removed coroutine (removal happens later in
async_scope_notify_coroutine_finished, ext/async/scope.c:881) → assertion at ext/async/scope.c:1189.
HttpServer::stop() does not drain or cancel server_scope — there is an explicit TODO:
src/core/http_server_class.c:2808-2809:
/* TODO: Wait for active connections with timeout
* Then cancel server scope to terminate all connection coroutines */
Expected behaviour / fix direction
Before server_scope is disposed, the server must ensure no internal coroutine is still running:
- On shutdown, wait for active per-request handler coroutines to finish (with a timeout).
- Whatever has not finished within the timeout, cancel it and then await the cancellation to complete.
- Only then allow
server_scope disposal (server object free).
Notes / constraints:
- Do not call
dispose() on the scope directly to force this — use the proper scope cancellation/await API (the scope has a dedicated method for orderly cancellation; dispose is not it).
stop() may be invoked from within a handler coroutine, so the drain/await should happen on the start() lifecycle coroutine (after wait_event resolves), not by blocking inside stop().
Scope
- Bug is in the server shutdown lifecycle (
src/core/http_server_class.c), exposed via request_scope's per-request child scopes.
request_scope correctness itself is fine (tests: tests/phpt/server/core/040,042,043 + new H2/H3 coverage all pass).
Summary
When
HttpServeris torn down (its PHP object freed) while a per-request handler coroutine is still in-flight / suspended, the server scope is disposed while a child per-request scope still holds that coroutine. In a debug build this aborts with:The
request_scopefeature itself works correctly for normal request handling (per-request isolation + subtree inheritance, verified on H1/H2/H3). This is a graceful-shutdown gap, surfaced by the per-request child scopes that the feature introduces.Reproduction
A handler that suspends (e.g.
Async\delay) is still parked when the server is stopped/destroyed:Result: abort at
ext/async/scope.c:1189(debug build). Without the per-request scope (handlers spawned directly inserver_scope) the abort does not occur.Root cause (call chain)
http_server_free()releases the last ref on the server-scope zend object:src/core/http_server_class.c:3352—OBJ_RELEASE(scope_object);scope_objectand callstry_to_dispose→scope_dispose(server_scope).ext/async/coroutine.c:707—ZEND_ASYNC_SCOPE_CATCH(coroutine->coroutine.scope, ...).scope_catch_or_cancelpropagates up the hierarchy:ext/async/scope.c:1047—return parent_scope->catch_or_cancel(...).scope_dispose:ext/async/scope.c:1029—ZEND_ASYNC_CALLBACKS_NOTIFY(&async_scope->scope.event, ...).scope_disposecascades into child scopes:ext/async/scope.c:1196—child_scope->scope.event.dispose(...).async_scope_notify_coroutine_finished,ext/async/scope.c:881) → assertion atext/async/scope.c:1189.HttpServer::stop()does not drain or cancelserver_scope— there is an explicit TODO:src/core/http_server_class.c:2808-2809:Expected behaviour / fix direction
Before
server_scopeis disposed, the server must ensure no internal coroutine is still running:server_scopedisposal (server object free).Notes / constraints:
dispose()on the scope directly to force this — use the proper scope cancellation/await API (the scope has a dedicated method for orderly cancellation;disposeis not it).stop()may be invoked from within a handler coroutine, so the drain/await should happen on thestart()lifecycle coroutine (afterwait_eventresolves), not by blocking insidestop().Scope
src/core/http_server_class.c), exposed viarequest_scope's per-request child scopes.request_scopecorrectness itself is fine (tests:tests/phpt/server/core/040,042,043+ new H2/H3 coverage all pass).