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>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-28 16:45:45 +00:00
committed by GitHub
parent 50643afd6a
commit 877968d8e5
2 changed files with 61 additions and 3 deletions

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')

View File

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