From fc4bb548a4f2faa6f15ec2e95bdb55e8c5043e99 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:49:18 +0200 Subject: [PATCH] aio-interface: fix nginx reverse proxy read timeout blocks AIO from starting consecutive containers (#8016) * 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> * As heartbeat send a dot regularly Rather than repeating the message, send a "magic" dot, which gets appended to the previous line. Previously the heartbeats weren't sent regulary because reading the data into a buffer caused a lag. Signed-off-by: Pablo Zmdl * Remove left-over constants Signed-off-by: Pablo Zmdl * Reduce timeout to stay within nginx's default timeout of 5s Signed-off-by: Pablo Zmdl * Fix duplicated library namespace inclusion Signed-off-by: Pablo Zmdl * Deal with preg_replace possibly returning null Signed-off-by: Pablo Zmdl * update the version tag Signed-off-by: Simon L. --------- Signed-off-by: Pablo Zmdl Signed-off-by: Simon L. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: szaimen <42591237+szaimen@users.noreply.github.com> Co-authored-by: Pablo Zmdl Co-authored-by: Simon L. --- php/public/scroll-into-view.js | 4 +++ php/src/Controller/DockerController.php | 24 ++++++++++---- php/src/Docker/DockerActionManager.php | 42 ++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/php/public/scroll-into-view.js b/php/public/scroll-into-view.js index 2c676911..3e4b9574 100644 --- a/php/public/scroll-into-view.js +++ b/php/public/scroll-into-view.js @@ -4,6 +4,10 @@ const observer = new MutationObserver((records) => { // function being present. if (node && typeof(node.scrollIntoView) === 'function') { node.scrollIntoView(); + if (node.classList.contains('progress-indicator')) { + node.previousSibling.append('.'); + node.remove(); + } } }); observer.observe(document, {childList: true, subtree: true}); diff --git a/php/src/Controller/DockerController.php b/php/src/Controller/DockerController.php index 1e50278a..089256b9 100644 --- a/php/src/Controller/DockerController.php +++ b/php/src/Controller/DockerController.php @@ -420,7 +420,7 @@ readonly class DockerController { - + @@ -428,6 +428,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') @@ -448,12 +453,19 @@ readonly class DockerController { // if it'll actually pull an image), but which should not need to know anything about the // wanted markup or formatting. $addToStreamingResponseBody = function (string $message, ?Container $container = null) use ($nonbufResp) : void { - // Strip ANSI codes. - $message = preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $message); - if ($container) { - $message = "{$container->displayName}: {$message}"; + // If the message is a single dot we treat it as a progress indicator and send a specific, empty + // HTML element, which gets special treatment by the Javascript code. + if ($message === '.') { + $html = ""; + } else { + // Strip ANSI codes. If the operation fails, use the unchanged $message as fallback. + $text = preg_replace('/\e[[][A-Za-z0-9];?[0-9]*m?/', '', $message) ?? $message; + if ($container) { + $text = sprintf("%s: %s", $container->displayName, $text); + } + $html = sprintf("
%s
", htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')); } - $nonbufResp->getBody()->write("
" . htmlspecialchars("{$message}", ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "
"); + $nonbufResp->getBody()->write($html); }; return $addToStreamingResponseBody; diff --git a/php/src/Docker/DockerActionManager.php b/php/src/Docker/DockerActionManager.php index 173e4d5f..539f01db 100644 --- a/php/src/Docker/DockerActionManager.php +++ b/php/src/Docker/DockerActionManager.php @@ -11,12 +11,13 @@ use AIO\Data\ConfigurationManager; use AIO\Data\DataConst; use AIO\Helper\NetworkHelper; use GuzzleHttp\Client; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Psr7\Utils; +use GuzzleHttp\Exception\RequestException; use http\Env\Response; readonly class DockerActionManager { private const string API_VERSION = 'v1.44'; + private const int PULL_HEARTBEAT_INTERVAL_SECONDS = 4; private Client $guzzleClient; public function __construct( @@ -570,10 +571,43 @@ readonly class DockerActionManager { $maxRetries = 3; for ($attempt = 1; $attempt <= $maxRetries; $attempt++) { try { - $this->sendHttpRequest('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->sendHttpRequest('POST', $url, ['stream' => true]); + $pullBody = $pullResponse->getBody(); + $pullErrors = []; + $lastHeartbeat = time(); + while (!$pullBody->eof()) { + $line = Utils::readLine($pullBody); + $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(); + $interval = time() - $lastHeartbeat; + if ($interval >= self::PULL_HEARTBEAT_INTERVAL_SECONDS) { + $addToStreamingResponseBody(".", $container); + $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);