diff --git a/app/controllers.php b/app/controllers.php index cb7e5e5..801196d 100644 --- a/app/controllers.php +++ b/app/controllers.php @@ -9,6 +9,7 @@ use Utopia\Http\Request; use Utopia\Http\Http; use Utopia\Http\Response; +use Utopia\Http\Adapter\Swoole\Response as SwooleResponse; use Utopia\Validator\AnyOf; use Utopia\Validator\Assoc; use Utopia\Validator\Boolean; @@ -224,28 +225,119 @@ function ( $variables = array_map(strval(...), $variables); - $execution = $runner->createExecution( - $runtimeId, - $payload, - $path, - $method, - $headers, - $timeout, - $image, - $source, - $entrypoint, - $variables, - $cpus, - $memory, - $version, - $runtimeEntrypoint, - $logging, - $restartPolicy, - ); + $responseFormat = $request->getHeaderLine('x-executor-response-format') ?: RESPONSE_FORMAT_STRING_HEADERS; + + $acceptTypes = \explode(', ', $request->getHeaderLine('accept') ?: 'multipart/form-data'); + $isJson = array_any($acceptTypes, fn ($acceptType): bool => \str_starts_with((string) $acceptType, 'application/json') || \str_starts_with((string) $acceptType, 'application/*')); + + $boundary = BodyMultipart::generateBoundary(); + $onHeaders = null; + $onBody = null; + $streamStarted = false; + + // Writes one whole part. Content is length prefixed as `\r\n\r\n` and the + // part is closed by a zero length run, so a reader locates content by its length and + // never scans for the boundary. An empty value writes no run at all, since a zero + // length run is what closes the part. + $writePart = function (string $name, string $content) use ($boundary, $response, &$streamStarted): void { + $streamStarted = true; + $response->chunk( + '--' . $boundary . "\r\n" + . 'Content-Disposition: form-data; name="' . $name . '"' . "\r\n" + . "Content-Transfer-Encoding: chunked\r\n\r\n" + . ($content === '' ? '' : \dechex(\strlen($content)) . "\r\n" . $content . "\r\n") + . "0\r\n\r\n" + ); + }; + + if (!$isJson && \version_compare($responseFormat, RESPONSE_FORMAT_STREAM, '>=')) { + /** + * @param array $headers + */ + $onHeaders = function (int $statusCode, array $headers) use ($response, $boundary, $writePart): void { + $encoded = \json_encode($headers); + + // Nothing has been written yet, so this can still surface as an error. + if ($encoded === false) { + throw new Exception(Exception::GENERAL_UNKNOWN, 'Response headers could not be encoded', 500); + } + + // Last chance to set headers: chunk() commits them on its first call. + $response + ->setStatusCode(Response::STATUS_CODE_OK) + ->addHeader('content-type', 'multipart/form-data; boundary=' . $boundary) + ->addHeader('x-executor-response-format', RESPONSE_FORMAT_STREAM); + + $writePart('statusCode', \strval($statusCode)); + $writePart('headers', $encoded); + + $response->chunk( + '--' . $boundary . "\r\n" + . 'Content-Disposition: form-data; name="body"' . "\r\n" + . "Content-Transfer-Encoding: chunked\r\n\r\n" + ); + }; + + $onBody = function (string $chunk) use ($response): void { + // curl hands over empty writes, and a zero length run would close the part. + if ($chunk === '') { + return; + } + + $response->chunk(\dechex(\strlen($chunk)) . "\r\n" . $chunk . "\r\n"); + }; + } + + try { + $execution = $runner->createExecution( + $runtimeId, + $payload, + $path, + $method, + $headers, + $timeout, + $image, + $source, + $entrypoint, + $variables, + $cpus, + $memory, + $version, + $runtimeEntrypoint, + $logging, + $restartPolicy, + onHeaders: $onHeaders, + onBody: $onBody, + ); + } catch (\Throwable $throwable) { + if (!$streamStarted) { + throw $throwable; + } + + // Content is committed, so the error hook's JSON would land inside the envelope. + if ($response instanceof SwooleResponse) { + $response->getSwooleResponse()->close(); + } + + return; + } + + // The body already left, so only the trailing metadata is still owed. + if ($streamStarted) { + $response->chunk("0\r\n\r\n"); // closes the body part + + foreach (['logs', 'errors', 'duration', 'startTime'] as $key) { + $writePart($key, \strval($execution[$key] ?? '')); + } + + $response->chunk('--' . $boundary . '--'); + $response->chunk('', true); + + return; + } // Backwards compatibility for headers - $responseFormat = $request->getHeaderLine('x-executor-response-format') ?: '0.10.0'; // Last version without support for array value for headers - if (version_compare($responseFormat, '0.11.0', '<')) { + if (version_compare($responseFormat, RESPONSE_FORMAT_ARRAY_HEADERS, '<')) { foreach ($execution['headers'] as $key => $value) { if (\is_array($value)) { $lastKey = \array_key_last($value); @@ -254,9 +346,6 @@ function ( } } - $acceptTypes = \explode(', ', $request->getHeaderLine('accept') ?: 'multipart/form-data'); - $isJson = array_any($acceptTypes, fn ($acceptType): bool => \str_starts_with((string) $acceptType, 'application/json') || \str_starts_with((string) $acceptType, 'application/*')); - if ($isJson) { $executionString = \json_encode($execution, JSON_UNESCAPED_UNICODE); if (!$executionString) { diff --git a/app/init.php b/app/init.php index a8c15f5..38447d5 100644 --- a/app/init.php +++ b/app/init.php @@ -17,6 +17,18 @@ const MAX_LOG_SIZE = 5 * 1024 * 1024; const MAX_BUILD_LOG_SIZE = 1000 * 1000; +// Response formats negotiated via x-executor-response-format. A caller sends +// the newest format it understands; no header means the oldest. +const RESPONSE_FORMAT_STRING_HEADERS = '0.10.0'; + +// First response format whose header values may be arrays, keeping every value +// of a duplicated response header instead of only the last. +const RESPONSE_FORMAT_ARRAY_HEADERS = '0.11.0'; + +// First response format whose parts are length prefixed, so a caller can read them as they are +// framed instead of waiting for the whole document to be serialised. +const RESPONSE_FORMAT_STREAM = '0.12.0'; + Config::load('errors', __DIR__ . '/config/errors.php'); $container = new Container(); diff --git a/src/Executor/Runner/Adapter.php b/src/Executor/Runner/Adapter.php index c7bac75..1c2f15d 100644 --- a/src/Executor/Runner/Adapter.php +++ b/src/Executor/Runner/Adapter.php @@ -55,6 +55,8 @@ abstract public function createExecution( bool $logging, string $restartPolicy, string $region = '', + ?callable $onHeaders = null, + ?callable $onBody = null, ): mixed; abstract public function getRuntimes(): mixed; diff --git a/src/Executor/Runner/Docker.php b/src/Executor/Runner/Docker.php index db90fe8..684cae9 100644 --- a/src/Executor/Runner/Docker.php +++ b/src/Executor/Runner/Docker.php @@ -693,6 +693,8 @@ public function createExecution( bool $logging, string $restartPolicy, string $region = '', + ?callable $onHeaders = null, + ?callable $onBody = null, ): mixed { $runtimeName = System::getHostname() . '-' . $runtimeId; @@ -901,7 +903,10 @@ public function createExecution( ]; }; - $executeV5 = function () use ($path, $method, $headers, $payload, $secret, $hostname, $timeout, $runtimeName, $logging): array { + // Once set, the response is committed: it can be neither retried nor turned into an error. + $streamed = false; + + $executeV5 = function () use ($path, $method, $headers, $payload, $secret, $hostname, $timeout, $runtimeName, $logging, $onHeaders, $onBody, &$streamed): array { $statusCode = 0; $errNo = -1; $executorResponse = ''; @@ -922,7 +927,10 @@ public function createExecution( \curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); } - \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + if ($onHeaders === null || $onBody === null) { + \curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + } + \curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders): int { $len = strlen($header); $header = explode(':', $header, 2); @@ -950,6 +958,30 @@ public function createExecution( return $len; }); + if ($onHeaders !== null && $onBody !== null) { + \curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($curl, $data) use ($onHeaders, $onBody, &$responseHeaders, &$streamed): int { + if (!$streamed) { + $streamed = true; + + $outputHeaders = []; + foreach ($responseHeaders as $key => $value) { + if (\str_starts_with($key, 'x-open-runtimes-')) { + continue; + } + + $outputHeaders[$key] = $value; + } + + // The header callback has already run, so the status is known here. + $onHeaders(\intval(\curl_getinfo($curl, CURLINFO_HTTP_CODE)), $outputHeaders); + } + + $onBody($data); + + return \strlen($data); + }); + } + \curl_setopt($ch, CURLOPT_TIMEOUT, (int) $timeout + 5); // Gives extra 5s after safe timeout to recieve response \curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5); $headers['x-open-runtimes-logging'] = $logging ? 'enabled' : 'disabled'; @@ -966,7 +998,9 @@ public function createExecution( \curl_setopt($ch, CURLOPT_HEADEROPT, CURLHEADER_UNIFIED); \curl_setopt($ch, CURLOPT_HTTPHEADER, $headersArr); - $executorResponse = \curl_exec($ch); + // With a write callback the body is already forwarded, so curl_exec returns a bool. + $result = \curl_exec($ch); + $executorResponse = \is_string($result) ? $result : ''; $statusCode = \curl_getinfo($ch, CURLINFO_HTTP_CODE); @@ -1098,6 +1132,11 @@ public function createExecution( break; } + // A retry would append a second response to content already on the wire. + if ($streamed) { + break; + } + usleep($retryDelayMs * 1000); } while ((++$attempts < $retryAttempts) || (\microtime(true) - $startTime < $timeout)); diff --git a/tests/e2e/StreamedResponseTest.php b/tests/e2e/StreamedResponseTest.php new file mode 100644 index 0000000..989b977 --- /dev/null +++ b/tests/e2e/StreamedResponseTest.php @@ -0,0 +1,259 @@ +client = new Client($this->endpoint, [ + 'content-type' => 'application/json', + ]); + $this->client->setKey($this->key); + } + + /** + * Reads the 0.12.0 envelope. Mirrors the reader on the caller's side: content is located by its + * length prefix and never by scanning for the boundary. + * + * @return array{parts: array, runs: array, order: array} + */ + private function parse(string $boundary, string $wire): array + { + $parts = []; + $runs = []; + $order = []; + $offset = 0; + $delimiter = '--' . $boundary; + + while (true) { + $this->assertSame($delimiter, \substr($wire, $offset, \strlen($delimiter)), 'expected delimiter at ' . $offset); + $offset += \strlen($delimiter); + + if (\substr($wire, $offset, 2) === '--') { + break; // closing delimiter + } + + $this->assertSame("\r\n", \substr($wire, $offset, 2)); + $offset += 2; + + $end = \strpos($wire, "\r\n\r\n", $offset); + $this->assertNotFalse($end, 'unterminated part headers'); + $headers = \substr($wire, $offset, $end - $offset); + $offset = $end + 4; + + $this->assertMatchesRegularExpression('/Content-Transfer-Encoding:\s*chunked/i', $headers, 'part is not chunked: ' . $headers); + $matches = []; + $this->assertSame(1, \preg_match('/name="([^"]+)"/', $headers, $matches), 'part has no name: ' . $headers); + $name = $matches[1] ?? ''; + + $order[] = $name; + $parts[$name] = ''; + $runs[$name] = 0; + + while (true) { + $eol = \strpos($wire, "\r\n", $offset); + $this->assertNotFalse($eol, 'unterminated chunk size'); + $size = \substr($wire, $offset, $eol - $offset); + $this->assertMatchesRegularExpression('/^[0-9a-fA-F]+$/', $size, 'bad chunk size "' . $size . '"'); + $offset = $eol + 2; + + $length = \intval($size, 16); + + if ($length === 0) { + $this->assertSame("\r\n", \substr($wire, $offset, 2), 'part terminator must be followed by CRLF'); + $offset += 2; + break; + } + + $parts[$name] .= \substr($wire, $offset, $length); + $runs[$name]++; + $offset += $length; + + $this->assertSame("\r\n", \substr($wire, $offset, 2), 'chunk must be followed by CRLF'); + $offset += 2; + } + } + + return ['parts' => $parts, 'runs' => $runs, 'order' => $order]; + } + + private function build(string $folder, string $image, string $entrypoint, string $buildCommand, string $runtimeId): string + { + $output = ''; + $stderr = ''; + Console::execute('cd /app/tests/resources/functions/' . $folder . ' && tar --exclude code.tar.gz -czf code.tar.gz .', '', $output, $stderr); + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], [ + 'runtimeId' => $runtimeId . '-build', + 'source' => '/storage/functions/' . $folder . '/code.tar.gz', + 'destination' => '/storage/builds/' . $runtimeId, + 'entrypoint' => $entrypoint, + 'image' => $image, + 'command' => 'tar -zxf /tmp/code.tar.gz -C /mnt/code && bash helpers/build.sh "' . $buildCommand . '"', + 'remove' => true, + ]); + + $this->assertEquals(201, $response['headers']['status-code'], 'build failed: ' . \json_encode($response)); + + return $response['body']['path']; + } + + /** + * @param array $params + * @return array{wire: string, headers: array} + */ + private function executeStreamed(string $runtimeId, array $params, string $format = '0.12.0'): array + { + $wire = ''; + + $response = $this->client->call( + Client::METHOD_POST, + '/runtimes/' . $runtimeId . '/executions', + [ + 'x-executor-response-format' => $format, + 'accept' => 'multipart/form-data', + ], + $params, + false, + function (string $data) use (&$wire): void { + $wire .= $data; + } + ); + + return ['wire' => $wire, 'headers' => $response['headers']]; + } + + public function testStreamedEnvelopeIsFramedAndOrdered(): void + { + $runtimeId = 'stream-' . \bin2hex(\random_bytes(4)); + $buildPath = $this->build('php-mock', 'openruntimes/php:v5-8.1', 'index.php', 'composer install', $runtimeId); + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], [ + 'runtimeId' => $runtimeId, + 'source' => $buildPath, + 'entrypoint' => 'index.php', + 'image' => 'openruntimes/php:v5-8.1', + 'runtimeEntrypoint' => 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "php src/server.php"', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $result = $this->executeStreamed($runtimeId, []); + + // The executor must announce the format, otherwise a caller cannot tell it apart from a + // buffered document and will not attempt an incremental read. + $this->assertEquals('0.12.0', $result['headers']['x-executor-response-format'] ?? null, 'format not echoed: ' . \json_encode($result['headers'])); + + $contentType = $result['headers']['content-type'] ?? ''; + $this->assertStringStartsWith('multipart/form-data', (string) $contentType); + $boundary = \trim(\explode('boundary=', (string) $contentType)[1] ?? '', '"'); + + $parsed = $this->parse($boundary, $result['wire']); + + $this->assertSame( + ['statusCode', 'headers', 'body', 'logs', 'errors', 'duration', 'startTime'], + $parsed['order'], + // statusCode and headers precede the body: the proxy cannot set either once content + // has started going out to the visitor. + 'unexpected part order' + ); + + $this->assertSame('200', $parsed['parts']['statusCode']); + + $headers = \json_decode($parsed['parts']['headers'], true); + $this->assertIsArray($headers); + $this->assertArrayHasKey('content-type', $headers); + + $body = \json_decode($parsed['parts']['body'], true); + $this->assertIsArray($body); + $this->assertTrue($body['isTest']); + $this->assertSame('Hello Open Runtimes 👋', $body['message']); + + $this->assertStringContainsString('Sample Log', $parsed['parts']['logs']); + $this->assertGreaterThan(0, (float) $parsed['parts']['duration']); + $this->assertGreaterThan(0, (float) $parsed['parts']['startTime']); + + $this->client->call(Client::METHOD_DELETE, '/runtimes/' . $runtimeId, [], []); + } + + public function testLargeBodyIsFramedAsSeveralRuns(): void + { + $runtimeId = 'stream-large-' . \bin2hex(\random_bytes(4)); + $buildPath = $this->build('node-large-response', 'openruntimes/node:v5-18.0', 'index.js', 'npm install', $runtimeId); + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], [ + 'runtimeId' => $runtimeId, + 'source' => $buildPath, + 'entrypoint' => 'index.js', + 'image' => 'openruntimes/node:v5-18.0', + 'runtimeEntrypoint' => 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "node src/server.js"', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $result = $this->executeStreamed($runtimeId, [ + 'headers' => ['x-response-size' => '1048576'], + ]); + + $this->assertEquals('0.12.0', $result['headers']['x-executor-response-format'] ?? null); + + $contentType = (string) ($result['headers']['content-type'] ?? ''); + $boundary = \trim(\explode('boundary=', $contentType)[1] ?? '', '"'); + $parsed = $this->parse($boundary, $result['wire']); + + $this->assertGreaterThanOrEqual(1048576, \strlen($parsed['parts']['body'])); + $this->assertSame('200', $parsed['parts']['statusCode']); + + // The whole point: a body this size is forwarded as it arrives, so it is framed as many + // runs. One run would mean the executor buffered it after all. + $this->assertGreaterThan( + 1, + $parsed['runs']['body'], + 'body was framed as a single run, so it was buffered rather than streamed' + ); + + + $this->client->call(Client::METHOD_DELETE, '/runtimes/' . $runtimeId, [], []); + } + + public function testOlderFormatStillGetsBufferedDocument(): void + { + $runtimeId = 'stream-compat-' . \bin2hex(\random_bytes(4)); + $buildPath = $this->build('php-mock', 'openruntimes/php:v5-8.1', 'index.php', 'composer install', $runtimeId); + + $response = $this->client->call(Client::METHOD_POST, '/runtimes', [], [ + 'runtimeId' => $runtimeId, + 'source' => $buildPath, + 'entrypoint' => 'index.php', + 'image' => 'openruntimes/php:v5-8.1', + 'runtimeEntrypoint' => 'cp /tmp/code.tar.gz /mnt/code/code.tar.gz && nohup helpers/start.sh "php src/server.php"', + ]); + $this->assertEquals(201, $response['headers']['status-code']); + + $result = $this->executeStreamed($runtimeId, [], '0.11.0'); + + // No echo, so a caller keeps buffering, and no part carries a chunked encoding. + $this->assertArrayNotHasKey('x-executor-response-format', $result['headers']); + $this->assertStringNotContainsStringIgnoringCase('Content-Transfer-Encoding: chunked', $result['wire']); + $this->assertStringContainsString('Hello Open Runtimes', $result['wire']); + + $this->client->call(Client::METHOD_DELETE, '/runtimes/' . $runtimeId, [], []); + } +} diff --git a/tests/resources/functions/node-large-response/index.js b/tests/resources/functions/node-large-response/index.js new file mode 100644 index 0000000..b1fdf95 --- /dev/null +++ b/tests/resources/functions/node-large-response/index.js @@ -0,0 +1,10 @@ +module.exports = async (context) => { + const size = parseInt(context.req.headers['x-response-size'] ?? '1048576', 10); + const line = 'A'.repeat(63) + '\n'; + let body = ''; + while (body.length < size) { + body += line; + } + + return context.res.text(body, 200, { 'content-type': 'text/plain' }); +} diff --git a/tests/resources/functions/node-large-response/package.json b/tests/resources/functions/node-large-response/package.json new file mode 100644 index 0000000..1d47001 --- /dev/null +++ b/tests/resources/functions/node-large-response/package.json @@ -0,0 +1,12 @@ +{ + "name": "large-response", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +}