diff --git a/CHANGELOG.md b/CHANGELOG.md index e771eeec..e43a1b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/dev/PLAN.md b/dev/PLAN.md index d0d4f8a2..e89f8765 100644 --- a/dev/PLAN.md +++ b/dev/PLAN.md @@ -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 diff --git a/src/grpc/grpc_call.c b/src/grpc/grpc_call.c index 00275d9b..d920894c 100644 --- a/src/grpc/grpc_call.c +++ b/src/grpc/grpc_call.c @@ -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) { diff --git a/src/grpc/grpc_call.h b/src/grpc/grpc_call.h index 1f90c358..3836a20d 100644 --- a/src/grpc/grpc_call.h +++ b/src/grpc/grpc_call.h @@ -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, diff --git a/src/http2/http2_session.c b/src/http2/http2_session.c index cb6f8980..94731fe5 100644 --- a/src/http2/http2_session.c +++ b/src/http2/http2_session.c @@ -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; } @@ -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; } diff --git a/src/http2/http2_strategy.c b/src/http2/http2_strategy.c index b5e7cf49..874cb817 100644 --- a/src/http2/http2_strategy.c +++ b/src/http2/http2_strategy.c @@ -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 diff --git a/src/http3/http3_callbacks.c b/src/http3/http3_callbacks.c index b9e264ea..25754a49 100644 --- a/src/http3/http3_callbacks.c +++ b/src/http3/http3_callbacks.c @@ -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; } @@ -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; } diff --git a/src/http3/http3_dispatch.c b/src/http3/http3_dispatch.c index fde8a588..3ad2fcf5 100644 --- a/src/http3/http3_dispatch.c +++ b/src/http3/http3_dispatch.c @@ -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 @@ -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). diff --git a/src/http_request.c b/src/http_request.c index 398c2320..fd94d1e9 100644 --- a/src/http_request.c +++ b/src/http_request.c @@ -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; @@ -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); diff --git a/tests/phpt/server/grpc/020-grpc-refused-body-status.phpt b/tests/phpt/server/grpc/020-grpc-refused-body-status.phpt new file mode 100644 index 00000000..a405681b --- /dev/null +++ b/tests/phpt/server/grpc/020-grpc-refused-body-status.phpt @@ -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-- + true]); +?> +--FILE-- +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 diff --git a/tests/phpt/server/grpc/021-grpc-h3-refused-body-status.phpt b/tests/phpt/server/grpc/021-grpc-h3-refused-body-status.phpt new file mode 100644 index 00000000..eb40a516 --- /dev/null +++ b/tests/phpt/server/grpc/021-grpc-h3-refused-body-status.phpt @@ -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-- + true, 'aioquic' => true]); +?> +--FILE-- +/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 diff --git a/tests/phpt/server/grpc/022-grpc-h3-pool-refused-body-status.phpt b/tests/phpt/server/grpc/022-grpc-h3-pool-refused-body-status.phpt new file mode 100644 index 00000000..bf219e01 --- /dev/null +++ b/tests/phpt/server/grpc/022-grpc-h3-pool-refused-body-status.phpt @@ -0,0 +1,85 @@ +--TEST-- +gRPC over HTTP/3 under the reactor pool: a request body past setMaxBodySize ends with grpc-status 8 (#426) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true, 'aioquic' => true]); +?> +--ENV-- +TRUE_ASYNC_SERVER_REACTOR_POOL=1 +PHP_HTTP3_DISABLE_RETRY=1 +--FILE-- +/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) + ->setWorkers(2); + +$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(600000); + + $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"; +--EXPECTF-- +%Asaw_status_200=1 +grpc_status=8 +saw_grpc_message=1 +%A diff --git a/tests/phpt/server/grpc/023-grpc-h2-streaming-live-cap-status.phpt b/tests/phpt/server/grpc/023-grpc-h2-streaming-live-cap-status.phpt new file mode 100644 index 00000000..e453cc61 --- /dev/null +++ b/tests/phpt/server/grpc/023-grpc-h2-streaming-live-cap-status.phpt @@ -0,0 +1,69 @@ +--TEST-- +gRPC over HTTP/2: a streamed message past the live-bytes cap ends with grpc-status 8 (#426) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setMaxBodySize(8 * 1024) + ->setBodyStreamingEnabled(true) + ->setReadTimeout(5) + ->setWriteTimeout(5)); + +$server->addGrpcHandler(function ($req, $resp) { + $req->readMessage(); + $resp->writeMessage('unexpected'); +}); + +$client = spawn(function () use ($port, $server) { + usleep(80000); + + $msg = str_repeat('a', 64 * 1024); + $msg = str_repeat('a', 64 * 1024); + + $verbose = grpc_h2_upload_timed($port, "\x00" . pack('N', strlen($msg)) . $msg); + + 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 diff --git a/tests/phpt/server/grpc/024-grpc-h2-refused-body-read-again-status.phpt b/tests/phpt/server/grpc/024-grpc-h2-refused-body-read-again-status.phpt new file mode 100644 index 00000000..910e50fd --- /dev/null +++ b/tests/phpt/server/grpc/024-grpc-h2-refused-body-read-again-status.phpt @@ -0,0 +1,84 @@ +--TEST-- +gRPC over HTTP/2: a handler that catches the refusal and reads again gets 413 again, and the call ends with grpc-status 8 (#426) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setMaxBodySize(16 * 1024) + ->setReadTimeout(5) + ->setWriteTimeout(5)); + +$seen = []; + +$server->addGrpcHandler(function ($req, $resp) use (&$seen) { + try { + $req->awaitBody(); + $seen[] = "first: no refusal"; + } catch (HttpException $e) { + $seen[] = "first: " . $e->getCode(); + } + + try { + $req->readMessage(); + $seen[] = "second: no refusal"; + } catch (HttpException $e) { + $seen[] = "second: " . $e->getCode(); + } +}); + +$client = spawn(function () use ($port, $server, &$seen) { + usleep(80000); + + $msg = str_repeat('a', 64 * 1024); + + $verbose = grpc_h2_upload_timed($port, "\x00" . pack('N', strlen($msg)) . $msg); + + 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"; + + echo implode("\n", $seen), "\n"; + + $server->stop(); +}); + +$server->start(); +await($client); +echo "done\n"; +?> +--EXPECT-- +http 200: 1 +grpc-status: 8 +grpc-message: 1 +error reset: 0 +first: 413 +second: 413 +done diff --git a/tests/phpt/server/grpc/025-grpc-h2-refused-body-handler-status.phpt b/tests/phpt/server/grpc/025-grpc-h2-refused-body-handler-status.phpt new file mode 100644 index 00000000..10e0a4d0 --- /dev/null +++ b/tests/phpt/server/grpc/025-grpc-h2-refused-body-handler-status.phpt @@ -0,0 +1,70 @@ +--TEST-- +gRPC over HTTP/2: a handler that catches a refused body keeps the grpc-status it sets (#426) +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + true]); +?> +--FILE-- +addListener('127.0.0.1', $port) + ->setMaxBodySize(16 * 1024) + ->setReadTimeout(5) + ->setWriteTimeout(5)); + +$server->addGrpcHandler(function ($req, $resp) { + try { + $req->awaitBody(); + } catch (HttpException $e) { + $resp->setTrailer('grpc-status', '9') + ->setTrailer('grpc-message', 'handler says no'); + } +}); + +$client = spawn(function () use ($port, $server) { + usleep(80000); + + $msg = str_repeat('a', 64 * 1024); + + $verbose = grpc_h2_upload_timed($port, "\x00" . pack('N', strlen($msg)) . $msg); + + 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: ', preg_match('/< grpc-message: ([^\r\n]*)/', $verbose, $m) ? $m[1] : 'none', "\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: 9 +grpc-message: handler says no +error reset: 0 +done diff --git a/tests/phpt/server/grpc/_grpc_h2_upload.inc b/tests/phpt/server/grpc/_grpc_h2_upload.inc new file mode 100644 index 00000000..2c6c6dfe --- /dev/null +++ b/tests/phpt/server/grpc/_grpc_h2_upload.inc @@ -0,0 +1,41 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open([ + h2_curl_bin(), '--http2-prior-knowledge', '-s', '-v', '--max-time', '5', + '-X', 'POST', '-T', '-', + '-H', 'content-type: application/grpc', '-H', 'te: trailers', + '-o', h2_dev_null(), "http://127.0.0.1:$port/svc/Upload", + ], $spec, $pipes); + if (!is_resource($proc)) { + return ''; + } + + fwrite($pipes[0], substr($body, 0, 1)); + fflush($pipes[0]); + usleep($delay_us); + fwrite($pipes[0], substr($body, 1)); + fclose($pipes[0]); + + $trace = (string) stream_get_contents($pipes[2]); + fclose($pipes[2]); + stream_get_contents($pipes[1]); + fclose($pipes[1]); + proc_close($proc); + + return $trace; +} diff --git a/tests/unit/common/strategy_stubs.c b/tests/unit/common/strategy_stubs.c index ffa59ccb..923ccf78 100644 --- a/tests/unit/common/strategy_stubs.c +++ b/tests/unit/common/strategy_stubs.c @@ -45,6 +45,12 @@ void grpc_call_ensure_status(zend_object *response_obj, bool had_exception) abort(); } +void grpc_call_ensure_refusal_status(zend_object *response_obj, uint16_t refused_status) +{ + (void) response_obj; (void) refused_status; + abort(); +} + void grpc_call_finish(zend_object *response_obj, const grpc_finish_ops_t *ops, void *ctx) { (void) response_obj; (void) ops; (void) ctx;