Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A gRPC call whose request body the server refused ended with a bare stream reset and no `grpc-status` (#426).** #412 answered refused bodies with their HTTP status and left gRPC on the error reset: curl read `ENHANCE_YOUR_CALM` and no status, and a gRPC client a transport error. A refused gRPC body now ends the call with `grpc-status: 8` (RESOURCE_EXHAUSTED, what grpc-go answers an over-size message) and a `grpc-message`, HTTP status 200, as a trailers-only response, then RST_STREAM(NO_ERROR) or STOP_SENDING(H3_NO_ERROR); under the HTTP/3 reactor pool the reactor answers it, for any request with a gRPC content type. A status the handler sets after catching the refusal is kept with its own message. `readMessage()` throws the refusal's `HttpException` 413 on every call after it, where a handler that caught the refusal and read again parked for good. For gRPC, `setMaxBodySize()` limits a body the handler has not started reading; a body it reads with `readMessage()` while it arrives is held to the live-bytes cap. A gRPC response the handler is still streaming keeps the error reset. Evidence: `grpc/020` to `grpc/025` fail before and pass 10 of 10 after.
- **On HTTP/3 the server wrote packets from inside ngtcp2's packet read, which ngtcp2 forbids (#428).** A static file or `sendFile()` response finishes when nghttp3 closes its stream, and nghttp3 does that from inside `ngtcp2_conn_read_pkt` while an ACK is processed; the dispose tail that runs then called `ngtcp2_conn_writev_stream`, which `ngtcp2.h` says must not be called from a callback, and could re-enter a drain already in progress. No failure was traced to it, but a debug assertion that no drain runs inside `read_pkt` failed 9 h3 tests (`h3/039`, `052`, `053`, `054`, `090`, `091`, `099`, `101`, `102`). A drain requested there now marks the connection, and the read path flushes it once the datagram is processed; the assertion stays in debug builds, so a write from a callback fails the h3 suite.
- **Over HTTP/2 and HTTP/3 a request body the server refused reached the client as a bare reset, with no status (#412).** A body past `setMaxBodySize()`, or a multipart form over `max_input_vars` or malformed, reset the stream at once: HTTP/2 sent RST_STREAM(ENHANCE_YOUR_CALM) or (CANCEL), HTTP/3 STOP_SENDING and RESET_STREAM(H3_REQUEST_REJECTED), and the 413 or 400 the handler's exception carried could not be sent on a stream already closing. A handler that caught the refusal and answered itself was lost the same way, and so was a response already decided when the upload crossed the cap: an early 403 in flight on HTTP/3 arrived as 27,925 of 1,048,576 bytes, and a finished `sendFile()` on HTTP/2 was followed by RST_STREAM 11. Now a refusal with no response decided cancels the handler with `HttpException` 413 or 400 and answers that status, and a decided response is delivered whole; then the upload is stopped with RST_STREAM(NO_ERROR) or STOP_SENDING(H3_NO_ERROR), as RFC 9113 §8.1 and RFC 9114 §4.1 allow and nginx does. Under the reactor pool the reactor answers the 413 itself. A streamed response a handler is still writing, a WebSocket stream and a gRPC call keep the error reset. A client reset after a caught refusal reaches the handler as 499, not as the refusal again. Evidence: `h2/074` to `h2/081`, `h2/083`, `h3/104` to `h3/106`, `h3/109` fail before and pass 10 of 10 after; `h2/061`, `h2/066` to `h2/068` now expect the status.
- **Every refused streaming HTTP/2 or HTTP/3 body kept part of the connection's flow-control window for good (#412).** The bytes queued and not yet read when the body was refused, and the DATA chunk that crossed the cap, were never credited back to the connection, so enough refusals on one connection stalled it: with a 60 KiB cap, the 257th refused upload on an HTTP/2 connection met GOAWAY and the next request got no answer. They are credited at the refusal now, and DATA arriving after it is credited to the connection alone (`h2/081`: 300 refused uploads, then a request answered 200).
Expand Down
12 changes: 11 additions & 1 deletion dev/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,17 @@ Added by the health check of 2026-09-30 (`dev/HEALTH.md`), in this order:
same condition failed 9 h3 tests without the deferral and none with it;
full phpt 629 passed, 2 skipped. T1, no Critic.
48. #426 — a refused gRPC request body resets the stream with no
`grpc-status`. Reproduce before any code.
`grpc-status`. Done: reproduced (curl read ENHANCE_YOUR_CALM, no status);
gRPC goes through #412's judgement, dispose sets grpc-status 8 (13 for a
500) and grpc-message unless the handler set a status, the reactor answers
trailers-only by `grpc_mode`, `readMessage()` throws a refusal. Critic on
the design (unsound: the reactor had no gRPC flag and its trailers never
left; a caught refusal parked readMessage) and on the diff (022 ran
in-thread without `--ENV--`; readMessage's wait loop threw RuntimeException
0; no test of a handler's own status), all taken. `grpc/020`-`025` red on
the base, 10 of 10 after; full phpt 635 passed, 2 skipped.
49. #329 — the accept split on the shared listen fd. Edmond, 2026-10-20:
possibly a Linux trait rather than a bug. Check before any code.
43. #339 — a client filename with path components: done. Edmond, 2026-10-19:
behave as PHP. The name is cut after the last `/` or `\`
(`php_ap_basename`, static in `main/rfc1867.c`, so copied) and the file is
Expand Down
23 changes: 23 additions & 0 deletions src/grpc/grpc_call.c
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ void grpc_call_ensure_status(zend_object *response_obj, bool failed)
failed ? GRPC_STATUS_INTERNAL : GRPC_STATUS_OK);
}

void grpc_call_ensure_refusal_status(zend_object *response_obj,
const uint16_t refused_status)
{
const HashTable *const trailers = http_response_get_trailers(response_obj);
const bool handler_set_status = trailers != NULL
&& zend_hash_str_exists(trailers, "grpc-status", sizeof("grpc-status") - 1);

http_response_ensure_grpc_status(response_obj,
refused_status == 413 ? GRPC_STATUS_RESOURCE_EXHAUSTED : GRPC_STATUS_INTERNAL);

if (handler_set_status) {
return;
}

/* The refusal messages are printable ASCII without '%', which is what
* grpc-message takes unencoded; no percent-encoding is needed. */
const char *const message = http_stream_refusal_message(refused_status);

http_response_static_set_trailer(response_obj, "grpc-message",
sizeof("grpc-message") - 1,
message, strlen(message));
}

void grpc_call_finish(zend_object *response_obj,
const grpc_finish_ops_t *ops, void *ctx)
{
Expand Down
8 changes: 8 additions & 0 deletions src/grpc/grpc_call.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ void grpc_call_init_response(zend_object *response_obj, int grpc_mode);
* fail to reach it: a thrown exception and a zend_bailout. */
void grpc_call_ensure_status(zend_object *response_obj, bool failed);

/* The call's body was refused (@p refused_status, as http_stream_exception_new
* takes it): grpc-status RESOURCE_EXHAUSTED for 413, as gRPC answers an
* over-size message, INTERNAL for the rest, whether the handler failed or not.
* A status the handler set stays, and then so does its message; the
* response's own 200 is never touched. */
void grpc_call_ensure_refusal_status(zend_object *response_obj,
uint16_t refused_status);

/* Dispose-time delivery: grpc-web → in-body trailer frame + end; streaming
* → end_stream; zero-message → Trailers-Only commit. */
void grpc_call_finish(zend_object *response_obj,
Expand Down
13 changes: 8 additions & 5 deletions src/http2/http2_session.c
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,10 @@ static void http2_request_body_upgrade(http_request_t *req)
* into req->body) before the handler's first read — there is nothing
* left to stream and nobody will ever signal EOF on the queue, so
* flipping body_streaming here would park the reader forever. Stay
* buffered; callers fall back to the req->body cursor path. */
if (req->body_streaming || req->complete) {
* buffered; callers fall back to the req->body cursor path. A refused
* request stays as it is too: the reads throw, and a queue that nothing
* will end is no place to park them. */
if (req->body_streaming || req->complete || req->refused_status != 0) {
return;
}

Expand Down Expand Up @@ -481,13 +483,14 @@ typedef enum {
} h2_refusal_t;

/* What a refusal does to the stream, by what the stream has already got.
* WebSocket and gRPC keep the error reset: a gRPC refusal already ends in a
* grpc-status, a separate question. A response a live handler is still
* WebSocket keeps the error reset: its frames have no status to carry one. A
* gRPC call is judged like any stream, its answer being a grpc-status
* (grpc_call_ensure_refusal_status). A response a live handler is still
* writing is the one case a decided answer cannot be told from a broken one,
* so it is cut as before. */
static h2_refusal_t h2_judge_refusal(const http2_stream_t *stream)
{
if (stream->is_websocket || stream->is_grpc) {
if (stream->is_websocket) {
return H2_REFUSAL_RESET;
}

Expand Down
11 changes: 8 additions & 3 deletions src/http2/http2_strategy.c
Original file line number Diff line number Diff line change
Expand Up @@ -643,9 +643,14 @@ static void http2_handler_coroutine_dispose(zend_coroutine_t *coroutine)

/* gRPC outcome → grpc-status trailer (policy in src/grpc/grpc_call.c). */
if (stream->is_grpc && !Z_ISUNDEF(stream->response_zv)) {
grpc_call_ensure_status(Z_OBJ(stream->response_zv),
coroutine->exception != NULL
|| stream->handler_bailout);
if (stream->request != NULL && stream->request->refused_status != 0) {
grpc_call_ensure_refusal_status(Z_OBJ(stream->response_zv),
stream->request->refused_status);
} else {
grpc_call_ensure_status(Z_OBJ(stream->response_zv),
coroutine->exception != NULL
|| stream->handler_bailout);
}
}

/* sendFile() handoff (issue #13). Take the descriptor before
Expand Down
17 changes: 7 additions & 10 deletions src/http3/http3_callbacks.c
Original file line number Diff line number Diff line change
Expand Up @@ -222,17 +222,13 @@ typedef enum {
} h3_refusal_t;

/* What a refusal does to the stream, by what the stream has already got.
* Mirrors h2_judge_refusal. A gRPC stream keeps the reset, its answer being
* a grpc-status, a separate question. A response a live handler is still
* writing is the one case a decided answer cannot be told from a broken one,
* so it is cut as before. */
* Mirrors h2_judge_refusal. A gRPC call is judged like any stream, its answer
* being a grpc-status (grpc_call_ensure_refusal_status). A response a live
* handler is still writing is the one case a decided answer cannot be told
* from a broken one, so it is cut as before. */
static h3_refusal_t h3_judge_refusal(const http3_connection_t *c,
const http3_stream_t *s)
{
if (s->is_grpc) {
return H3_REFUSAL_RESET;
}

if (s->static_owned || (s->coroutine == NULL && s->response_decided)) {
return H3_REFUSAL_FINISH;
}
Expand Down Expand Up @@ -576,8 +572,9 @@ static void http3_request_body_upgrade(http_request_t *req)
{
/* Request already complete: body finalized into req->body, no EOF will
* ever reach the streaming queue — upgrading now would park the reader
* forever. Stay buffered (see http2_request_body_upgrade). */
if (req->body_streaming || req->complete) {
* forever. Stay buffered (see http2_request_body_upgrade); so does a
* refused request. */
if (req->body_streaming || req->complete || req->refused_status != 0) {
return;
}

Expand Down
28 changes: 24 additions & 4 deletions src/http3/http3_dispatch.c
Original file line number Diff line number Diff line change
Expand Up @@ -638,8 +638,23 @@ void http3_reactor_answer_refusal(http3_connection_t *c, http3_stream_t *s,
http_response_set_protocol_version(Z_OBJ(s->response_zv), "3.0");
http_response_set_head(Z_OBJ(s->response_zv),
http_request_method_is_head(s->request));
http_response_reset_to_error(Z_OBJ(s->response_zv), status,
http_stream_refusal_message(status));

/* No worker has seen the request, so s->is_grpc is not set yet; the
* mode stamped at headers-complete tells. The call is answered as a
* gRPC one is without a message: a Trailers-Only 200 whose headers carry
* the status (the trailers promoted, as grpc_call_finish does). */
if (s->request->grpc_mode != GRPC_MODE_NONE) {
zend_object *const response = Z_OBJ(s->response_zv);

grpc_call_init_response(response, s->request->grpc_mode);
grpc_call_ensure_refusal_status(response, status);
http_response_promote_trailers_to_headers(response);
http_response_set_committed(response);
} else {
http_response_reset_to_error(Z_OBJ(s->response_zv), status,
http_stream_refusal_message(status));
}

s->response_decided = true;

/* The HANDLED shape of http3_reactor_try_static: a serving ref keeps the
Expand Down Expand Up @@ -1188,8 +1203,13 @@ static void h3_handler_coroutine_dispose(zend_coroutine_t *coroutine)

/* gRPC outcome → grpc-status trailer (policy in src/grpc/grpc_call.c). */
if (s->is_grpc && !Z_ISUNDEF(s->response_zv)) {
grpc_call_ensure_status(Z_OBJ(s->response_zv),
coroutine->exception != NULL || s->handler_bailout);
if (s->request != NULL && s->request->refused_status != 0) {
grpc_call_ensure_refusal_status(Z_OBJ(s->response_zv),
s->request->refused_status);
} else {
grpc_call_ensure_status(Z_OBJ(s->response_zv),
coroutine->exception != NULL || s->handler_bailout);
}
}

/* Streaming-vs-buffered decision (mirror of H2 dispose).
Expand Down
13 changes: 13 additions & 0 deletions src/http_request.c
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage)
RETURN_NULL();
}

/* A refused body is incomplete: a read after the handler caught the
* refusal must throw it again, not park on a body that will not come or
* answer null as if the client had half-closed. */
if (http_request_body_refused(req)) {
RETURN_THROWS();
}

zend_string *msg = NULL;
bool compressed = false;
int rc;
Expand Down Expand Up @@ -464,6 +471,12 @@ ZEND_METHOD(TrueAsync_HttpRequest, readMessage)
}

if (req->body_eof) {
/* A refusal that lands while this call waits ends the queue in
* error; it throws its status, as the call would have at entry. */
if (UNEXPECTED(http_request_body_refused(req))) {
RETURN_THROWS();
}

if (UNEXPECTED(req->body_error)) {
zend_throw_exception(http_server_runtime_exception_ce,
"gRPC request body stream error", 0);
Expand Down
78 changes: 78 additions & 0 deletions tests/phpt/server/grpc/020-grpc-refused-body-status.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
--TEST--
gRPC over HTTP/2: a request body past setMaxBodySize ends with grpc-status 8, not a bare reset (#426)
--EXTENSIONS--
true_async_server
true_async
--SKIPIF--
<?php
require __DIR__ . '/../h2/_h2_skipif.inc';
h2_skipif(['curl_h2' => true]);
?>
--FILE--
<?php
/* A gRPC call whose message is past the body limit was refused with
* RST_STREAM(ENHANCE_YOUR_CALM): the client read a transport error and no
* grpc-status. The refusal now answers the call as gRPC does an over-size
* message, RESOURCE_EXHAUSTED (8) in a trailers-only response, and then stops
* the upload with RST_STREAM(NO_ERROR). */

use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use function Async\spawn;
use function Async\await;

require __DIR__ . '/../h2/_h2_skipif.inc';
require_once __DIR__ . '/../_free_port.inc';

$port = tas_free_port();
$server = new HttpServer((new HttpServerConfig())
->addListener('127.0.0.1', $port)
->setMaxBodySize(16 * 1024)
->setReadTimeout(5)
->setWriteTimeout(5));

/* awaitBody() first, so the body is buffered and the cumulative limit is
* what refuses it; a handler already in readMessage() streams the body and
* meets the live-bytes cap instead (grpc/023). */
$server->addGrpcHandler(function ($req, $resp) {
$req->awaitBody();
$req->readMessage();
$resp->writeMessage('unexpected');
});

$client = spawn(function () use ($port, $server) {
usleep(80000);

$msg = str_repeat('a', 64 * 1024);
$body = tempnam(sys_get_temp_dir(), 'grpcbig');
file_put_contents($body, "\x00" . pack('N', strlen($msg)) . $msg);

$verbose = (string) shell_exec(sprintf(
'curl --http2-prior-knowledge -s -v --max-time 5 -H %s -H %s --data-binary @%s -o %s '
. 'http://127.0.0.1:%d/svc/Upload 2>&1',
escapeshellarg('content-type: application/grpc'),
escapeshellarg('te: trailers'),
escapeshellarg($body),
escapeshellarg(h2_dev_null()),
$port));
@unlink($body);

echo 'http 200: ', (int) (preg_match('/< HTTP\/2 200/', $verbose) === 1), "\n";
echo 'grpc-status: ', preg_match('/< grpc-status: (\d+)/', $verbose, $m) ? $m[1] : 'none', "\n";
echo 'grpc-message: ', (int) (stripos($verbose, '< grpc-message:') !== false), "\n";
echo 'error reset: ', (int) (stripos($verbose, 'not closed cleanly') !== false
&& stripos($verbose, 'NO_ERROR') === false), "\n";

$server->stop();
});

$server->start();
await($client);
echo "done\n";
?>
--EXPECT--
http 200: 1
grpc-status: 8
grpc-message: 1
error reset: 0
done
82 changes: 82 additions & 0 deletions tests/phpt/server/grpc/021-grpc-h3-refused-body-status.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
--TEST--
gRPC over HTTP/3: a request body past setMaxBodySize ends with grpc-status 8, not a bare reset (#426)
--EXTENSIONS--
true_async_server
true_async
--SKIPIF--
<?php
require __DIR__ . '/../h3/_h3_skipif.inc';
h3_skipif(['openssl_cli' => true, 'aioquic' => true]);
?>
--FILE--
<?php
/* A gRPC call whose message is past the body limit was refused with
* STOP_SENDING and RESET_STREAM(H3_REQUEST_REJECTED): the client read no
* grpc-status. The refusal now answers RESOURCE_EXHAUSTED (8) in a
* trailers-only response and stops only the read side. aioquic drives it,
* since the C h3client reads no trailers. */

use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;
use function Async\spawn;
use function Async\await;

$tmp = __DIR__ . '/tmp-grpc-h3-021';
@mkdir($tmp, 0700, true);
$cert = $tmp . '/cert.pem';
$key = $tmp . '/key.pem';
$rc = 0;
exec(sprintf('openssl req -x509 -newkey rsa:2048 -sha256 -days 1 -nodes '
. '-subj "/CN=localhost" -keyout %s -out %s 2>/dev/null',
escapeshellarg($key), escapeshellarg($cert)), $_, $rc);
if ($rc !== 0) { echo "cert gen failed\n"; exit(1); }

register_shutdown_function(function () use ($tmp) {
@unlink("$tmp/cert.pem"); @unlink("$tmp/key.pem"); @rmdir($tmp);
});

require_once __DIR__ . '/../_free_port.inc';

$port = tas_free_port_span(2);
$config = (new HttpServerConfig())
->addListener('127.0.0.1', $port + 1)
->addHttp3Listener('127.0.0.1', $port)
->setCertificate($cert)->setPrivateKey($key)
->setMaxBodySize(64 * 1024)
->setReadTimeout(5)->setWriteTimeout(5);

$server = new HttpServer($config);
$server->addGrpcHandler(function ($req, $resp) {
$req->awaitBody();
$req->readMessage();
$resp->writeMessage('unexpected');
});

$py = __DIR__ . '/_h3grpc_client.py';

$client = spawn(function () use ($server, $port, $py) {
usleep(200000);

$msg = str_repeat('a', 200 * 1024);
$hexfile = tempnam(sys_get_temp_dir(), 'grpch3');
file_put_contents($hexfile, bin2hex("\x00" . pack('N', strlen($msg)) . $msg));
$cmd = sprintf('python3 %s 127.0.0.1 %d /svc/Upload application/grpc %s 2>/dev/null',
escapeshellarg($py), $port, escapeshellarg('@' . $hexfile));
$out = (string) shell_exec($cmd);
@unlink($hexfile);

echo "saw_status_200=", (int)(strpos($out, 'HDR :status: 200') !== false), "\n";
echo "grpc_status=", preg_match('/^HDR grpc-status: (\d+)$/m', $out, $m) ? $m[1] : 'none', "\n";
echo "saw_grpc_message=", (int)(strpos($out, 'HDR grpc-message:') !== false), "\n";

$server->stop();
});

$server->start();
await($client);
echo "Done\n";
--EXPECT--
saw_status_200=1
grpc_status=8
saw_grpc_message=1
Done
Loading
Loading