From 877968d8e5601770ef1f1c4d63350fd835a9e6ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:45:45 +0000 Subject: [PATCH] Fix: prevent nginx proxy read timeout from blocking AIO container startup When AIO runs behind an nginx reverse proxy and a user clicks Start, image pulls produce no streaming output for minutes at a time. nginx's proxy_read_timeout fires, drops the upstream connection, and PHP then aborts on the next write attempt (ignore_user_abort defaults to false), leaving all containers after the first one never started. Two fixes: 1. startStreamingResponse(): add ignore_user_abort(true) so PHP never terminates if the connection is already gone. 2. PullImage(): stream the Docker NDJSON pull response and write a "Pulling image" heartbeat at most once every 5 s, keeping the nginx connection alive. Also surfaces Docker-level stream errors that the old buffered call silently ignored, guards against malformed newline-free responses with a 1 MB buffer limit, and unifies the duplicate catch-block retry logic. Agent-Logs-Url: https://github.com/nextcloud/all-in-one/sessions/4fd13605-63fb-4693-8a95-89ccec31f7d3 Co-authored-by: szaimen <42591237+szaimen@users.noreply.github.com> --- php/src/Controller/DockerController.php | 5 +++ php/src/Docker/DockerActionManager.php | 59 +++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php index 66e8a3e2..8401b11a 100644 --- a/php/src/Controller/DockerController.php +++ b/php/src/Controller/DockerController.php @@ -410,6 +410,11 @@ readonly class DockerController { } private function startStreamingResponse(Response $response) : Response { + // Ensure the script keeps running even if the client connection drops (e.g. due to a + // reverse proxy read timeout during a long image pull). Without this, PHP would abort + // on the first write after the connection is gone, leaving only some containers started. + ignore_user_abort(true); + $nonbufResp = $response ->withBody(new NonBufferedBody()) ->withHeader('Content-Type', 'text/html; charset=utf-8') diff --git a/php/src/Docker/DockerActionManager.php b/php/src/Docker/DockerActionManager.php index ca6a4d72..4a5d647e 100644 --- a/php/src/Docker/DockerActionManager.php +++ b/php/src/Docker/DockerActionManager.php @@ -15,6 +15,9 @@ use http\Env\Response; readonly class DockerActionManager { private const string API_VERSION = 'v1.44'; + private const int PULL_STREAM_READ_CHUNK_SIZE = 65536; + private const int PULL_STREAM_MAX_BUFFER_SIZE = 1048576; + private const int PULL_HEARTBEAT_INTERVAL_SECONDS = 5; private Client $guzzleClient; public function __construct( @@ -560,10 +563,60 @@ readonly class DockerActionManager { $maxRetries = 3; for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { try { - $this->guzzleClient->post($url); + // Use streaming so we can write heartbeat messages to the response while the + // image is being pulled. Without this, a long pull produces no output and a + // reverse proxy (nginx) can drop the connection after its read timeout expires. + // Once the connection is gone, PHP aborts on the next write and all consecutive + // containers are never started. + $pullResponse = $this->guzzleClient->post($url, ['stream' => true]); + $pullBody = $pullResponse->getBody(); + $buffer = ''; + $pullErrors = []; + $lastHeartbeat = 0; + while (!$pullBody->eof()) { + $chunk = $pullBody->read(self::PULL_STREAM_READ_CHUNK_SIZE); + if ($chunk === '') { + continue; + } + $buffer .= $chunk; + // Guard against malformed responses that contain no newlines. + if (strlen($buffer) > self::PULL_STREAM_MAX_BUFFER_SIZE && strpos($buffer, "\n") === false) { + error_log('Docker pull response buffer exceeded 1 MB without a newline, discarding buffer for ' . $imageName); + $buffer = ''; + continue; + } + while (($newlinePos = strpos($buffer, "\n")) !== false) { + $line = trim(substr($buffer, 0, $newlinePos)); + $buffer = substr($buffer, $newlinePos + 1); + if ($line === '') { + continue; + } + $event = json_decode($line, true); + if (!is_array($event)) { + continue; + } + if (isset($event['error'])) { + $pullErrors[] = $event['error']; + } elseif ($addToStreamingResponseBody !== null) { + // Write a heartbeat at most once every 5 seconds so the reverse + // proxy sees continuous data and does not close the connection. + $now = time(); + if ($now - $lastHeartbeat >= self::PULL_HEARTBEAT_INTERVAL_SECONDS) { + $addToStreamingResponseBody($container, "Pulling image"); + $lastHeartbeat = $now; + } + } + } + } + if ($pullErrors !== []) { + throw new \Exception(implode('; ', $pullErrors)); + } break; - } catch (RequestException $e) { - $message = "Could not pull image " . $imageName . " (attempt $attempt/$maxRetries): " . $e->getResponse()?->getBody()->getContents(); + } catch (\Exception $e) { + $errorDetails = $e instanceof RequestException + ? $e->getResponse()?->getBody()->getContents() + : $e->getMessage(); + $message = "Could not pull image " . $imageName . " (attempt $attempt/$maxRetries): " . $errorDetails; if ($attempt === $maxRetries) { if ($imageIsThere === false) { throw new \Exception($message);