Compare commits

...

3 Commits

Author SHA1 Message Date
Pablo Zmdl
b0ab901b31 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>
2026-05-21 13:24:04 +02:00
Pablo Zmdl
d58db9438e Fix streaming Docker API response
Previously the request wasn't proxied through the Docker socket because Guzzle
apparently doesn't use libcurl for such requests and thus the proxy option
doesn't apply.

Signed-off-by: Pablo Zmdl <pablo@nextcloud.com>
2026-05-21 13:20:30 +02:00
copilot-swe-agent[bot]
877968d8e5 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>
2026-04-28 16:45:45 +00:00
3 changed files with 59 additions and 4 deletions

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});

View File

@@ -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')
@@ -430,7 +435,14 @@ 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 (Container $container, string $message) use ($nonbufResp) : void {
$nonbufResp->getBody()->write("<div>{$container->displayName}: {$message}</div>");
// 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 === '.') {
$content = "<span class='progress-indicator'></span>";
} else {
$content = "<div>{$container->displayName}: {$message}</div>";
}
$nonbufResp->getBody()->write($content);
};
return $addToStreamingResponseBody;

View File

@@ -10,11 +10,15 @@ use AIO\ContainerDefinitionFetcher;
use AIO\Data\ConfigurationManager;
use AIO\Data\DataConst;
use GuzzleHttp\Client;
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_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 +564,45 @@ 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.
// We have to specify the proxy again, since when streaming, Guzzle apparently doesn't use
// libcurl and thus the curl option set when creating the client doesn't apply.
$pullResponse = $this->guzzleClient->post($url, ['proxy' => 'unix:///var/run/docker.sock', '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);