Skip to content

[Question] Http-server resumable upload #385

@JadRho

Description

@JadRho

Hello, the end goal is to make 2 separate scripts to transfer files and have the option to resume upload.
I started with a vibe-coded template as I am still wrapping my head around Amp.

The issue is that I am seeing a cap of 2 max connections when connection: keep-alive is set (default)
Here is the server+client as a debug script

<?php
require __DIR__ . "/vendor/autoload.php";

use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;
use Amp\Http\HttpStatus;
use Amp\Http\Server\DefaultErrorHandler;
use Amp\Http\Server\RequestHandler\ClosureRequestHandler;
use Amp\Http\Server\Request as ServerRequest;
use Amp\Http\Server\Response;
use Amp\Http\Server\Router;
use Amp\Http\Server\SocketHttpServer;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use function Amp\async;

// Start server inline
$logger = new Logger("test");
$logger->pushHandler(new StreamHandler(STDOUT));

$server = SocketHttpServer::createForDirectAccess($logger);
$errorHandler = new DefaultErrorHandler();
$router = new Router($server, $logger, $errorHandler);

$router->addRoute(
	"PATCH",
	"/test",
	new ClosureRequestHandler(function (ServerRequest $request) use (
		$logger,
	): Response {
		$body = $request->getBody();
		$body->increaseSizeLimit(100 * 1024 * 1024);
		$received = 0;
		while (($chunk = $body->read()) !== null) {
			$received += strlen($chunk);
		}
		$logger->info("PATCH received $received bytes");
		return new Response(HttpStatus::NO_CONTENT, [
			"x-received" => (string) $received,
		]);
	}),
);

$server->expose("0.0.0.0:18080");
$server->start($router, $errorHandler);

// Run client in same process
$client = new HttpClientBuilder()->followRedirects(0)->build();

$chunkSize = 512 * 1024;
$chunk = str_repeat("A", $chunkSize);

echo "Sending 5 PATCH requests on same connection...\n";

for ($i = 0; $i < 5; $i++) {
	$req = new Request("http://127.0.0.1:18080/test", "PATCH");
	$req->setHeader("content-type", "application/offset+octet-stream");
	$req->setHeader("content-length", (string) $chunkSize);
	$req->setHeader("upload-offset", (string) ($i * $chunkSize));
	//$req->setHeader("connection", "close");
	$req->setBody($chunk);

	$resp = $client->request($req);
	$resp->getBody()->buffer();

	echo "Request $i — status: " .
		$resp->getStatus() .
		" x-received: " .
		$resp->getHeader("x-received") .
		"\n";

	echo "Request $i headers:\n";
	foreach ($resp->getHeaders() as $k => $v) {
		echo "  $k: " . implode(", ", $v) . "\n";
	}
}

$server->stop();

Here is the output

[2026-03-30T06:58:00.269438+00:00] test.NOTICE: Total client connections are limited to 1000. [] []
[2026-03-30T06:58:00.271427+00:00] test.NOTICE: Client connections are limited to 10 per IP address (excluding localhost). [] []
[2026-03-30T06:58:00.271461+00:00] test.NOTICE: Request concurrency limited to 1000 simultaneous requests [] []
[2026-03-30T06:58:00.277372+00:00] test.NOTICE: Request methods restricted to GET, POST, PUT, PATCH, HEAD, OPTIONS, DELETE. [] []
[2026-03-30T06:58:00.277412+00:00] test.DEBUG: Starting server [] []
[2026-03-30T06:58:00.281930+00:00] test.INFO: Started server [] []
[2026-03-30T06:58:00.281969+00:00] test.INFO: Listening on http://0.0.0.0:18080/ [] []
Sending 5 PATCH requests on same connection...
[2026-03-30T06:58:00.355374+00:00] test.INFO: PATCH received 524288 bytes [] []
Request 0 — status: 204 x-received: 524288
Request 0 headers:
  x-received: 524288
  connection: keep-alive
  keep-alive: timeout=15
  date: Mon, 30 Mar 2026 06:58:00 GMT
  transfer-encoding: chunked
[2026-03-30T06:58:00.400123+00:00] test.INFO: PATCH received 524288 bytes [] []
Request 1 — status: 204 x-received: 524288
Request 1 headers:
  x-received: 524288
  connection: keep-alive
  keep-alive: timeout=15
  date: Mon, 30 Mar 2026 06:58:00 GMT
  transfer-encoding: chunked
PHP Fatal error:  Uncaught Amp\Http\Client\ParseException: Invalid status line: 0 in /srv/vendor/amphp/http-client/src/Connection/Internal/Http1Parser.php:158
Stack trace:
#0 /srv/vendor/amphp/http-client/src/Connection/Http1Connection.php(332): Amp\Http\Client\Connection\Internal\Http1Parser->parse()
#1 /srv/vendor/amphp/http-client/src/Connection/Http1Connection.php(264): Amp\Http\Client\Connection\Http1Connection->readResponse()
#2 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(430): Amp\Http\Client\Connection\Http1Connection->{closure:Amp\Http\Client\Connection\Http1Connection::request():255}()
#3 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(567): Revolt\EventLoop\Internal\AbstractDriver->invokeMicrotasks()
#4 [internal function]: Revolt\EventLoop\Internal\AbstractDriver->{closure:Revolt\EventLoop\Internal\AbstractDriver::createCallbackFiber():565}()
#5 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/DriverSuspension.php(64): Fiber->resume()
#6 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(430): Revolt\EventLoop\Internal\DriverSuspension::{closure:Revolt\EventLoop\Internal\DriverSuspension::resume():61}()
#7 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(621): Revolt\EventLoop\Internal\AbstractDriver->invokeMicrotasks()
#8 [internal function]: Revolt\EventLoop\Internal\AbstractDriver->{closure:Revolt\EventLoop\Internal\AbstractDriver::createCallbackFiber():565}()
#9 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/DriverSuspension.php(64): Fiber->resume()
#10 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(430): Revolt\EventLoop\Internal\DriverSuspension::{closure:Revolt\EventLoop\Internal\DriverSuspension::resume():61}()
#11 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(621): Revolt\EventLoop\Internal\AbstractDriver->invokeMicrotasks()
#12 [internal function]: Revolt\EventLoop\Internal\AbstractDriver->{closure:Revolt\EventLoop\Internal\AbstractDriver::createCallbackFiber():565}()
#13 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(503): Fiber->start()
#14 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(558): Revolt\EventLoop\Internal\AbstractDriver->invokeCallbacks()
#15 [internal function]: Revolt\EventLoop\Internal\AbstractDriver->{closure:Revolt\EventLoop\Internal\AbstractDriver::createLoopFiber():538}()
#16 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/AbstractDriver.php(96): Fiber->resume()
#17 /srv/vendor/revolt/event-loop/src/EventLoop/Internal/DriverSuspension.php(117): Revolt\EventLoop\Internal\AbstractDriver->{closure:Revolt\EventLoop\Internal\AbstractDriver::__construct():90}()
#18 /srv/vendor/amphp/amp/src/Future.php(251): Revolt\EventLoop\Internal\DriverSuspension->suspend()
#19 /srv/vendor/amphp/http-client/src/Connection/Http1Connection.php(279): Amp\Future->await()
#20 /srv/vendor/amphp/http-client/src/Connection/HttpStream.php(96): Amp\Http\Client\Connection\Http1Connection->request()
#21 /srv/vendor/amphp/http-client/src/functions.php(20): Amp\Http\Client\Connection\HttpStream->{closure:Amp\Http\Client\Connection\HttpStream::request():96}()
#22 /srv/vendor/amphp/http-client/src/Connection/HttpStream.php(96): Amp\Http\Client\processRequest()
#23 /srv/vendor/amphp/http-client/src/Connection/ConnectionLimitingPool.php(131): Amp\Http\Client\Connection\HttpStream->request()
#24 /srv/vendor/amphp/http-client/src/Connection/HttpStream.php(96): Amp\Http\Client\Connection\ConnectionLimitingPool->{closure:Amp\Http\Client\Connection\ConnectionLimitingPool::getStream():124}()
#25 /srv/vendor/amphp/http-client/src/functions.php(20): Amp\Http\Client\Connection\HttpStream->{closure:Amp\Http\Client\Connection\HttpStream::request():96}()
#26 /srv/vendor/amphp/http-client/src/Connection/HttpStream.php(96): Amp\Http\Client\processRequest()
#27 /srv/vendor/amphp/http-client/src/Interceptor/DecompressResponse.php(43): Amp\Http\Client\Connection\HttpStream->request()
#28 /srv/vendor/amphp/http-client/src/Connection/InterceptedStream.php(53): Amp\Http\Client\Interceptor\DecompressResponse->requestViaNetwork()
#29 /srv/vendor/amphp/http-client/src/functions.php(20): Amp\Http\Client\Connection\InterceptedStream->{closure:Amp\Http\Client\Connection\InterceptedStream::request():36}()
#30 /srv/vendor/amphp/http-client/src/Connection/InterceptedStream.php(36): Amp\Http\Client\processRequest()
#31 /srv/vendor/amphp/http-client/src/Interceptor/ModifyRequest.php(36): Amp\Http\Client\Connection\InterceptedStream->request()
#32 /srv/vendor/amphp/http-client/src/Connection/InterceptedStream.php(53): Amp\Http\Client\Interceptor\ModifyRequest->requestViaNetwork()
#33 /srv/vendor/amphp/http-client/src/functions.php(20): Amp\Http\Client\Connection\InterceptedStream->{closure:Amp\Http\Client\Connection\InterceptedStream::request():36}()
#34 /srv/vendor/amphp/http-client/src/Connection/InterceptedStream.php(36): Amp\Http\Client\processRequest()
#35 /srv/vendor/amphp/http-client/src/Interceptor/ModifyRequest.php(36): Amp\Http\Client\Connection\InterceptedStream->request()
#36 /srv/vendor/amphp/http-client/src/Connection/InterceptedStream.php(53): Amp\Http\Client\Interceptor\ModifyRequest->requestViaNetwork()
#37 /srv/vendor/amphp/http-client/src/functions.php(20): Amp\Http\Client\Connection\InterceptedStream->{closure:Amp\Http\Client\Connection\InterceptedStream::request():36}()
#38 /srv/vendor/amphp/http-client/src/Connection/InterceptedStream.php(36): Amp\Http\Client\processRequest()
#39 /srv/vendor/amphp/http-client/src/PooledHttpClient.php(36): Amp\Http\Client\Connection\InterceptedStream->request()
#40 /srv/vendor/amphp/http-client/src/functions.php(20): Amp\Http\Client\PooledHttpClient->{closure:Amp\Http\Client\PooledHttpClient::request():29}()
#41 /srv/vendor/amphp/http-client/src/PooledHttpClient.php(29): Amp\Http\Client\processRequest()
#42 /srv/vendor/amphp/http-client/src/Interceptor/RetryRequests.php(34): Amp\Http\Client\PooledHttpClient->request()
#43 /srv/vendor/amphp/http-client/src/InterceptedHttpClient.php(38): Amp\Http\Client\Interceptor\RetryRequests->request()
#44 /srv/vendor/amphp/http-client/src/functions.php(20): Amp\Http\Client\InterceptedHttpClient->{closure:Amp\Http\Client\InterceptedHttpClient::request():28}()
#45 /srv/vendor/amphp/http-client/src/InterceptedHttpClient.php(28): Amp\Http\Client\processRequest()
#46 /srv/vendor/amphp/http-client/src/Interceptor/ModifyRequest.php(48): Amp\Http\Client\InterceptedHttpClient->request()
#47 /srv/vendor/amphp/http-client/src/InterceptedHttpClient.php(38): Amp\Http\Client\Interceptor\ModifyRequest->request()
#48 /srv/vendor/amphp/http-client/src/functions.php(20): Amp\Http\Client\InterceptedHttpClient->{closure:Amp\Http\Client\InterceptedHttpClient::request():28}()
#49 /srv/vendor/amphp/http-client/src/InterceptedHttpClient.php(28): Amp\Http\Client\processRequest()
#50 /srv/vendor/amphp/http-client/src/HttpClient.php(33): Amp\Http\Client\InterceptedHttpClient->request()
#51 /srv/vendor/amphp/http-client/src/functions.php(30): Amp\Http\Client\HttpClient->{closure:Amp\Http\Client\HttpClient::request():33}()
#52 /srv/vendor/amphp/http-client/src/HttpClient.php(30): Amp\Http\Client\processRequest()
#53 /srv/debug.php(63): Amp\Http\Client\HttpClient->request()
#54 {main}
  thrown in /srv/vendor/amphp/http-client/src/Connection/Internal/Http1Parser.php on line 158

Setting $req->setHeader("connection", "close"); in the client fixes it.
Or setting $response->setHeader("connection", "close"); on the server also fixes it.

[2026-03-30T07:21:47.507749+00:00] test.NOTICE: Total client connections are limited to 1000. [] []
[2026-03-30T07:21:47.509760+00:00] test.NOTICE: Client connections are limited to 10 per IP address (excluding localhost). [] []
[2026-03-30T07:21:47.509805+00:00] test.NOTICE: Request concurrency limited to 1000 simultaneous requests [] []
[2026-03-30T07:21:47.515764+00:00] test.NOTICE: Request methods restricted to GET, POST, PUT, PATCH, HEAD, OPTIONS, DELETE. [] []
[2026-03-30T07:21:47.515816+00:00] test.DEBUG: Starting server [] []
[2026-03-30T07:21:47.520374+00:00] test.INFO: Started server [] []
[2026-03-30T07:21:47.520412+00:00] test.INFO: Listening on http://0.0.0.0:18080/ [] []
Sending 5 PATCH requests on same connection...
[2026-03-30T07:21:47.594326+00:00] test.INFO: PATCH received 524288 bytes [] []
Request 0 — status: 204 x-received: 524288
Request 0 headers:
  x-received: 524288
  connection: close
  date: Mon, 30 Mar 2026 07:21:47 GMT
[2026-03-30T07:21:47.640191+00:00] test.INFO: PATCH received 524288 bytes [] []
Request 1 — status: 204 x-received: 524288
Request 1 headers:
  x-received: 524288
  connection: close
  date: Mon, 30 Mar 2026 07:21:47 GMT
[2026-03-30T07:21:47.684191+00:00] test.INFO: PATCH received 524288 bytes [] []
Request 2 — status: 204 x-received: 524288
Request 2 headers:
  x-received: 524288
  connection: close
  date: Mon, 30 Mar 2026 07:21:47 GMT
[2026-03-30T07:21:47.728075+00:00] test.INFO: PATCH received 524288 bytes [] []
Request 3 — status: 204 x-received: 524288
Request 3 headers:
  x-received: 524288
  connection: close
  date: Mon, 30 Mar 2026 07:21:47 GMT
[2026-03-30T07:21:47.772027+00:00] test.INFO: PATCH received 524288 bytes [] []
Request 4 — status: 204 x-received: 524288
Request 4 headers:
  x-received: 524288
  connection: close
  date: Mon, 30 Mar 2026 07:21:47 GMT
[2026-03-30T07:21:47.772423+00:00] test.INFO: Stopping server [] []
[2026-03-30T07:21:47.773048+00:00] test.DEBUG: Stopped server [] []

Is this a bug regarding keep-alive or a limitation on the server(2 connection limit)?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions