@@ -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
0 commit comments