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 <pablo@nextcloud.com>

* Remove left-over constants

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>

* Reduce timeout to stay within nginx's default timeout of 5s

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>

* Fix duplicated library namespace inclusion

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>

* Deal with preg_replace possibly returning null

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>

* update the version tag

Signed-off-by: Simon L. <szaimen@e.mail.de>

---------

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>
Signed-off-by: Simon L. <szaimen@e.mail.de>
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 <pablo@nextcloud.com>
Co-authored-by: Simon L. <szaimen@e.mail.de>
This commit is contained in:
Copilot
2026-06-16 17:49:18 +02:00
committed by GitHub
parent d20eb5edda
commit fc4bb548a4
3 changed files with 60 additions and 10 deletions
+4
View File
@@ -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});
+18 -6
View File
@@ -420,7 +420,7 @@ readonly class DockerController {
<head>
<link rel="stylesheet" href="../../style.css?v12" media="all" />
<script type="text/javascript" src="../../apply-theme.js?v1"></script>
<script type="text/javascript" src="../../scroll-into-view.js"></script>
<script type="text/javascript" src="../../scroll-into-view.js?v1"></script>
</head>
<body>
@@ -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 = "<span class='progress-indicator'></span>";
} 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("<div>%s</div>", htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
}
$nonbufResp->getBody()->write("<div>" . htmlspecialchars("{$message}", ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "</div>");
$nonbufResp->getBody()->write($html);
};
return $addToStreamingResponseBody;
+38 -4
View File
@@ -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);