Skip to content

Server shutdown disposes server_scope while per-request coroutines are still in-flight #74

@EdmondDantes

Description

@EdmondDantes

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)

  1. http_server_free() releases the last ref on the server-scope zend object:
    src/core/http_server_class.c:3352OBJ_RELEASE(scope_object);
  2. The scope object dtor clears scope_object and calls try_to_disposescope_dispose(server_scope).
  3. 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:707ZEND_ASYNC_SCOPE_CATCH(coroutine->coroutine.scope, ...).
  4. scope_catch_or_cancel propagates up the hierarchy:
    ext/async/scope.c:1047return parent_scope->catch_or_cancel(...).
  5. The parent notifies its listeners, which runs scope_dispose:
    ext/async/scope.c:1029ZEND_ASYNC_CALLBACKS_NOTIFY(&async_scope->scope.event, ...).
  6. scope_dispose cascades into child scopes:
    ext/async/scope.c:1196child_scope->scope.event.dispose(...).
  7. 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:

  1. On shutdown, wait for active per-request handler coroutines to finish (with a timeout).
  2. Whatever has not finished within the timeout, cancel it and then await the cancellation to complete.
  3. 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).

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions