From 8e043325a54157c2fe220b5a0de5f15b676af8be Mon Sep 17 00:00:00 2001 From: AugustoLopezProcess Date: Fri, 12 Jun 2026 15:14:55 -0400 Subject: [PATCH 1/3] Adding try/finally if container rm -f --- .../Models/ScriptDockerCopyingFilesTrait.php | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/ProcessMaker/Models/ScriptDockerCopyingFilesTrait.php b/ProcessMaker/Models/ScriptDockerCopyingFilesTrait.php index 343694d3b9..6c9630ef21 100644 --- a/ProcessMaker/Models/ScriptDockerCopyingFilesTrait.php +++ b/ProcessMaker/Models/ScriptDockerCopyingFilesTrait.php @@ -23,20 +23,26 @@ trait ScriptDockerCopyingFilesTrait */ protected function executeCopying(array $options) { - $container = $this->createContainer($options['image'], $options['command'], $options['parameters']); - foreach ($options['inputs'] as $path => $data) { - $this->putInContainer($container, $path, $data); - } - $response = $this->startContainer($container, $options['timeout']); - $outputs = []; - foreach ($options['outputs'] as $name => $path) { - $outputs[$name] = $this->getFromContainer($container, $path); - } + $container = null; - exec(Docker::command() . ' rm ' . $container); - $response['outputs'] = $outputs; + try { + $container = $this->createContainer($options['image'], $options['command'], $options['parameters']); + foreach ($options['inputs'] as $path => $data) { + $this->putInContainer($container, $path, $data); + } + $response = $this->startContainer($container, $options['timeout']); + $outputs = []; + foreach ($options['outputs'] as $name => $path) { + $outputs[$name] = $this->getFromContainer($container, $path); + } + $response['outputs'] = $outputs; - return $response; + return $response; + } finally { + if ($container) { + exec(Docker::command() . ' rm -f ' . $container); + } + } } /** From a0aeee89ff166f4543d47721e64cc54116669338 Mon Sep 17 00:00:00 2001 From: AugustoLopezProcess Date: Fri, 12 Jun 2026 15:21:14 -0400 Subject: [PATCH 2/3] Adding ScriptDockerStreamingFilesTrait --- .../ScriptDockerStreamingFilesTrait.php | 433 ++++++++++++++++++ ProcessMaker/ScriptRunners/Base.php | 9 +- 2 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 ProcessMaker/Models/ScriptDockerStreamingFilesTrait.php diff --git a/ProcessMaker/Models/ScriptDockerStreamingFilesTrait.php b/ProcessMaker/Models/ScriptDockerStreamingFilesTrait.php new file mode 100644 index 0000000000..5d371b5dbd --- /dev/null +++ b/ProcessMaker/Models/ScriptDockerStreamingFilesTrait.php @@ -0,0 +1,433 @@ + + */ + private static array $streamingImageCache = []; + + /** + * Run a command in a docker container using tar streaming. + * + * @param array $options + * + * @return array + * @throws RuntimeException + */ + protected function executeStreaming(array $options) + { + if (!$this->imageSupportsStreaming($options['image'])) { + Log::debug('Docker image does not support streaming, falling back to copying', [ + 'image' => $options['image'], + ]); + + return $this->executeCopying($options); + } + + $inputTar = $this->buildUstarTar($options['inputs']); + $outputPaths = array_values($options['outputs']); + $outputArgs = implode(' ', array_map( + static fn ($path) => escapeshellarg(ltrim($path, '/')), + $outputPaths + )); + + $cmd = Docker::command(0) . sprintf( + ' run -i --rm --network=host %s %s /opt/executor/run-stream.sh %s', + $options['parameters'], + escapeshellarg($options['image']), + $outputArgs + ); + + ['stdout' => $stdout, 'stderr' => $stderr, 'returnCode' => $returnCode] = $this->runStreamingContainer( + $cmd, + $inputTar, + (int) $options['timeout'] + ); + $output = $this->filterStreamingStderr($stderr); + $line = $output[0] ?? ''; + + if ($returnCode) { + if ($returnCode == 137 || $returnCode == 9) { + Log::error('Script timed out'); + throw new ScriptTimeoutException(implode("\n", $output)); + } + Log::error('Script threw return code ' . $returnCode); + $message = implode("\n", $output); + $message .= "\n\nProcessMaker Stack:\n"; + $message .= (new \Exception)->getTraceAsString(); + throw new ScriptException($message); + } + + $outputFiles = $this->parseUstarTar($stdout); + $outputs = []; + foreach ($options['outputs'] as $name => $path) { + $normalized = ltrim($path, '/'); + $outputs[$name] = $outputFiles[$normalized] ?? $outputFiles[$path] ?? ''; + } + + return compact('line', 'output', 'returnCode', 'outputs'); + } + + /** + * Run a streaming docker process with stdin tar input and PHP-enforced timeout. + * + * Shell timeout (PROCESSMAKER_SCRIPTS_TIMEOUT) is not used here because it is + * unavailable on some hosts (e.g. macOS) and breaks proc_open stdin piping. + * + * @param string $cmd + * @param string $inputTar + * @param int $timeout + * + * @return array{stdout: string, stderr: string, returnCode: int} + */ + private function runStreamingContainer(string $cmd, string $inputTar, int $timeout): array + { + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($cmd, $descriptors, $pipes); + + if (!is_resource($process)) { + throw new RuntimeException('Unable to start streaming docker container'); + } + + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + + fwrite($pipes[0], $inputTar); + fclose($pipes[0]); + + $stdout = ''; + $stderr = ''; + $deadline = $timeout > 0 ? microtime(true) + $timeout : null; + + while (true) { + if ($deadline !== null && microtime(true) >= $deadline) { + $this->terminateStreamingProcess($process, [$pipes[1], $pipes[2]]); + Log::error('Script timed out'); + throw new ScriptTimeoutException( + __('Script took too long to complete. Consider increasing the timeout.') + . "\n" + . __('Timeout: :timeout seconds', ['timeout' => $timeout]) + ); + } + + $status = proc_get_status($process); + $read = []; + + if (!feof($pipes[1])) { + $read[] = $pipes[1]; + } + + if (!feof($pipes[2])) { + $read[] = $pipes[2]; + } + + if (!$status['running'] && $read === []) { + break; + } + + if ($read !== []) { + $seconds = 1; + $microseconds = 0; + + if ($deadline !== null) { + $remaining = $deadline - microtime(true); + + if ($remaining <= 0) { + continue; + } + + $seconds = (int) floor(min($remaining, 1)); + $microseconds = (int) round(min($remaining - $seconds, 1) * 1000000); + } + + $write = null; + $except = null; + $ready = @stream_select($read, $write, $except, $seconds, $microseconds); + + if ($ready === false) { + break; + } + + if ($ready > 0) { + foreach ($read as $stream) { + $chunk = fread($stream, 8192); + + if ($chunk === false || $chunk === '') { + continue; + } + + if ($stream === $pipes[1]) { + $stdout .= $chunk; + } else { + $stderr .= $chunk; + } + } + } + } elseif ($status['running']) { + usleep(100000); + } else { + break; + } + } + + $stdout .= stream_get_contents($pipes[1]) ?: ''; + $stderr .= stream_get_contents($pipes[2]) ?: ''; + + fclose($pipes[1]); + fclose($pipes[2]); + + $returnCode = proc_close($process); + + return compact('stdout', 'stderr', 'returnCode'); + } + + /** + * Forcefully stop a streaming docker process. + * + * @param resource $process + * @param array $pipes + * + * @return void + */ + private function terminateStreamingProcess($process, array $pipes = []): void + { + foreach ($pipes as $pipe) { + if (is_resource($pipe)) { + fclose($pipe); + } + } + + @proc_terminate($process, 9); + usleep(100000); + + $status = proc_get_status($process); + + if ($status['running']) { + @proc_terminate($process, 9); + } + + proc_close($process); + } + + /** + * Remove benign tar stderr lines that are not script failures. + * + * @param string $stderr + * + * @return array + */ + private function filterStreamingStderr(string $stderr): array + { + if ($stderr === '') { + return []; + } + + $lines = explode("\n", rtrim($stderr, "\n")); + + return array_values(array_filter($lines, static function (string $line): bool { + return !str_starts_with($line, 'tar: Removing leading '); + })); + } + + /** + * Check if the docker image supports streaming via run-stream.sh. + * + * @param string $image + * + * @return bool + */ + protected function imageSupportsStreaming(string $image): bool + { + if (array_key_exists($image, self::$streamingImageCache)) { + return self::$streamingImageCache[$image]; + } + + $cmd = Docker::command() . sprintf( + ' run --rm --entrypoint test %s -f /opt/executor/run-stream.sh', + escapeshellarg($image) + ); + exec($cmd, $output, $returnCode); + + return self::$streamingImageCache[$image] = ($returnCode === 0); + } + + /** + * Build a ustar tar archive from input files. + * + * @param array $inputs + * + * @return string + */ + protected function buildUstarTar(array $inputs): string + { + $tar = ''; + foreach ($inputs as $path => $content) { + $tar .= $this->buildUstarTarEntry($path, $content); + } + + $tar .= str_repeat("\0", 1024); + + $this->validateUstarTar($tar, $inputs); + + return $tar; + } + + /** + * Validate that a ustar tar archive contains the expected files. + * + * @param string $tar + * @param array $expected + * + * @return void + */ + protected function validateUstarTar(string $tar, array $expected): void + { + $parsed = $this->parseUstarTar($tar); + + foreach ($expected as $path => $content) { + $normalized = ltrim($path, '/'); + if (!array_key_exists($normalized, $parsed)) { + throw new RuntimeException("Tar validation failed: missing path {$path}"); + } + if ($parsed[$normalized] !== $content) { + throw new RuntimeException("Tar validation failed: content mismatch for {$path}"); + } + } + } + + /** + * Parse a ustar tar archive into path => content pairs. + * + * @param string $tar + * + * @return array + */ + protected function parseUstarTar(string $tar): array + { + $files = []; + $offset = 0; + $length = strlen($tar); + + while ($offset + 512 <= $length) { + $header = substr($tar, $offset, 512); + + if ($header === str_repeat("\0", 512)) { + break; + } + + $name = rtrim(substr($header, 0, 100), "\0"); + $prefix = rtrim(substr($header, 345, 155), "\0"); + $path = $prefix !== '' ? $prefix . '/' . $name : $name; + + $size = octdec(trim(substr($header, 124, 12))); + $offset += 512; + + if ($size > 0) { + $files[$path] = substr($tar, $offset, $size); + $offset += (int) (ceil($size / 512) * 512); + } else { + $files[$path] = ''; + } + } + + return $files; + } + + /** + * Build a single ustar tar entry. + * + * @param string $path + * @param string $content + * + * @return string + */ + private function buildUstarTarEntry(string $path, string $content): string + { + $path = ltrim($path, '/'); + $size = strlen($content); + + $header = str_repeat("\0", 512); + + if (strlen($path) > 100) { + $splitAt = strrpos(substr($path, 0, 155), '/'); + if ($splitAt === false) { + throw new RuntimeException("Tar path too long: {$path}"); + } + $prefix = substr($path, 0, $splitAt); + $name = substr($path, $splitAt + 1); + $this->writeTarField($header, 0, $name, 100); + $this->writeTarField($header, 345, $prefix, 155); + } else { + $this->writeTarField($header, 0, $path, 100); + } + + $this->writeTarField($header, 100, sprintf('%07o', 0644), 8); //Permissions + $this->writeTarField($header, 108, sprintf('%07o', 0), 8); //User - UID + $this->writeTarField($header, 116, sprintf('%07o', 0), 8); //Group - GID + $this->writeTarField($header, 124, sprintf('%011o', $size), 12); //Size + $this->writeTarField($header, 136, sprintf('%011o', time()), 12); //Modification Time + $header[156] = '0'; + $this->writeTarField($header, 257, 'ustar', 6); + $this->writeTarField($header, 263, '00', 2); + + $this->writeTarField($header, 148, sprintf('%07o', $this->calculateTarChecksum($header)), 8); + + $entry = $header . $content; + $padding = (512 - ($size % 512)) % 512; //Padding to make the entry a multiple of 512 bytes + + return $entry . str_repeat("\0", $padding); + } + + /** + * Write a field into a tar header block. + * + * @param string $header + * @param int $offset + * @param string $value + * @param int $length + * + * @return void + */ + private function writeTarField(string &$header, int $offset, string $value, int $length): void + { + $header = substr_replace($header, substr(str_pad($value, $length, "\0"), 0, $length), $offset, $length); + } + + /** + * Calculate the ustar header checksum. + * + * @param string $header + * + * @return int + */ + private function calculateTarChecksum(string $header): int + { + $header = substr_replace($header, str_repeat(' ', 8), 148, 8); + $checksum = 0; + + for ($i = 0; $i < 512; $i++) { + $checksum += ord($header[$i]); + } + + return $checksum; + } +} diff --git a/ProcessMaker/ScriptRunners/Base.php b/ProcessMaker/ScriptRunners/Base.php index c609f3a0d6..c7db816040 100644 --- a/ProcessMaker/ScriptRunners/Base.php +++ b/ProcessMaker/ScriptRunners/Base.php @@ -10,6 +10,7 @@ use ProcessMaker\Models\ScriptDockerBindingFilesTrait; use ProcessMaker\Models\ScriptDockerCopyingFilesTrait; use ProcessMaker\Models\ScriptDockerNayraTrait; +use ProcessMaker\Models\ScriptDockerStreamingFilesTrait; use ProcessMaker\Models\ScriptExecutor; use ProcessMaker\Models\User; use RuntimeException; @@ -19,6 +20,7 @@ abstract class Base use ScriptDockerCopyingFilesTrait; use ScriptDockerBindingFilesTrait; use ScriptDockerNayraTrait; + use ScriptDockerStreamingFilesTrait; const NAYRA_LANG = 'php-nayra'; @@ -128,8 +130,11 @@ public function run($code, array $data, array $config, $timeout, ?User $user, $s } // Execute docker - $executeMethod = config('app.processmaker_scripts_docker_mode') === 'binding' - ? 'executeBinding' : 'executeCopying'; + $executeMethod = match (config('app.processmaker_scripts_docker_mode')) { + 'binding' => 'executeBinding', + 'streaming' => 'executeStreaming', + default => 'executeCopying', + }; Log::debug('Executing docker ' . $this->getRunId() . ':', [ 'executeMethod' => $executeMethod, ]); From 54fdb4e08bf2204b6ed519567bff5a12bd443549 Mon Sep 17 00:00:00 2001 From: AugustoLopezProcess Date: Mon, 15 Jun 2026 09:04:06 -0400 Subject: [PATCH 3/3] Adding Tests for docker streaming test --- .../Docker/ScriptDockerStreamingTest.php | 264 ++++++++++++++++++ .../ScriptDockerStreamingFilesTraitTest.php | 99 +++++++ 2 files changed, 363 insertions(+) create mode 100644 tests/Feature/Docker/ScriptDockerStreamingTest.php create mode 100644 tests/unit/ProcessMaker/Models/ScriptDockerStreamingFilesTraitTest.php diff --git a/tests/Feature/Docker/ScriptDockerStreamingTest.php b/tests/Feature/Docker/ScriptDockerStreamingTest.php new file mode 100644 index 0000000000..698b5ce68a --- /dev/null +++ b/tests/Feature/Docker/ScriptDockerStreamingTest.php @@ -0,0 +1,264 @@ +mockDockerPath = tempnam(sys_get_temp_dir(), 'mock-docker-'); + unlink($this->mockDockerPath); + + file_put_contents($this->mockDockerPath, str_replace( + '__LOG_FILE__', + sys_get_temp_dir() . '/pm-mock-docker-rm.log', + $this->getMockDockerScript() + )); + chmod($this->mockDockerPath, 0755); + + $this->bootstrapLaravel([ + 'app.processmaker_scripts_docker' => $this->mockDockerPath, + 'app.processmaker_scripts_docker_host' => '', + 'app.processmaker_scripts_timeout' => 'timeout', + 'app.processmaker_scripts_home' => sys_get_temp_dir(), + ]); + + $this->harness = new ScriptDockerStreamingTestHarness(); + } + + /** + * Bootstrap a minimal Laravel container for config(), Docker, and Log facades. + * + * @param array $configValues + */ + private function bootstrapLaravel(array $configValues): void + { + $config = []; + foreach ($configValues as $key => $value) { + data_set($config, $key, $value); + } + + $app = new Application(dirname(__DIR__, 3)); + $app->singleton('config', fn () => new ConfigRepository($config)); + $app->singleton(DockerManager::class, fn () => new DockerManager()); + $app->singleton('log', fn () => new Logger('test', [new NullHandler()])); + + Facade::setFacadeApplication($app); + + AliasLoader::getInstance( + Facade::defaultAliases()->merge([ + 'Docker' => \ProcessMaker\Facades\Docker::class, + ])->all() + )->register(); + + if (!class_exists('ProcessMaker\\Models\\Log', false)) { + // ScriptDockerCopyingFilesTrait imports Log from the current namespace. + class_alias(LogFacade::class, 'ProcessMaker\\Models\\Log'); + } + } + + protected function tearDown(): void + { + if ($this->mockDockerPath && file_exists($this->mockDockerPath)) { + unlink($this->mockDockerPath); + } + + $logFile = sys_get_temp_dir() . '/pm-mock-docker-rm.log'; + if (file_exists($logFile)) { + unlink($logFile); + } + + parent::tearDown(); + } + + public function testStreamingModeExecutesWithMockedDocker(): void + { + putenv('PM_MOCK_STREAMING=1'); + + $options = [ + 'image' => 'processmaker/mock-streaming:latest', + 'command' => 'ignored', + 'parameters' => '', + 'timeout' => 30, + 'inputs' => [ + '/opt/executor/data.json' => '{"input":"value"}', + '/opt/executor/config.json' => '{}', + ], + 'outputs' => [ + 'response' => '/opt/executor/output.json', + ], + ]; + + $response = $this->harness->executeStreamingMode($options); + + $this->assertSame(0, $response['returnCode']); + $this->assertSame('{"mocked":true}', $response['outputs']['response']); + $this->assertTrue($this->harness->imageSupportsStreamingMode($options['image'])); + + putenv('PM_MOCK_STREAMING'); + } + + public function testStreamingModeFallsBackToCopyingWhenRunStreamMissing(): void + { + putenv('PM_MOCK_STREAMING=0'); + + $options = [ + 'image' => 'processmaker/mock-no-stream:latest', + 'command' => 'echo test', + 'parameters' => '', + 'timeout' => 30, + 'inputs' => [ + '/opt/executor/data.json' => '{"input":"value"}', + ], + 'outputs' => [ + 'response' => '/opt/executor/output.json', + ], + ]; + + $response = $this->harness->executeStreamingMode($options); + + $this->assertSame(0, $response['returnCode']); + $this->assertSame('{"copied":true}', $response['outputs']['response']); + $this->assertFalse($this->harness->imageSupportsStreamingMode($options['image'])); + + $logFile = sys_get_temp_dir() . '/pm-mock-docker-rm.log'; + $this->assertFileExists($logFile); + $this->assertStringContainsString('rm called', file_get_contents($logFile)); + + putenv('PM_MOCK_STREAMING'); + } + + public function testCopyingModeRemovesContainerWhenStartFails(): void + { + putenv('PM_MOCK_START_FAIL=1'); + + $options = [ + 'image' => 'processmaker/mock-copy:latest', + 'command' => 'echo test', + 'parameters' => '', + 'timeout' => 30, + 'inputs' => [ + '/opt/executor/data.json' => '{}', + ], + 'outputs' => [ + 'response' => '/opt/executor/output.json', + ], + ]; + + try { + $this->harness->executeCopyingMode($options); + $this->fail('Expected ScriptException was not thrown'); + } catch (ScriptException) { + // Expected from startContainer failure path + } + + $logFile = sys_get_temp_dir() . '/pm-mock-docker-rm.log'; + $this->assertFileExists($logFile); + $this->assertStringContainsString('rm called', file_get_contents($logFile)); + + putenv('PM_MOCK_START_FAIL'); + } + + private function getMockDockerScript(): string + { + return <<<'BASH' +#!/bin/bash +set -e + +LOG_FILE="__LOG_FILE__" +CMD="$1" +shift || true +REST="$*" + +case "$CMD" in + run) + if [[ "$REST" == *"test -f /opt/executor/run-stream.sh"* ]]; then + [[ "${PM_MOCK_STREAMING}" == "1" ]] && exit 0 || exit 1 + fi + if [[ "$REST" == *"run-stream.sh"* ]]; then + cat > /dev/null + TMPDIR=$(mktemp -d) + mkdir -p "$TMPDIR/opt/executor" + printf '%s' '{"mocked":true}' > "$TMPDIR/opt/executor/output.json" + tar cf - -C "$TMPDIR" opt/executor/output.json + rm -rf "$TMPDIR" + exit 0 + fi + ;; + create) + CIDFILE="" + PREV="" + for arg in $REST; do + if [[ "$PREV" == "--cidfile" ]]; then + CIDFILE="$arg" + fi + PREV="$arg" + done + if [[ -n "$CIDFILE" ]]; then + printf '%s' 'mock-container-id' > "$CIDFILE" + fi + exit 0 + ;; + cp) + if [[ "$REST" == mock-container-id:* ]]; then + DEST=$(echo "$REST" | awk '{print $NF}') + printf '%s' '{"copied":true}' > "$DEST" + exit 0 + fi + exit 0 + ;; + start) + if [[ "${PM_MOCK_START_FAIL}" == "1" ]]; then + echo "mock start failure" >&2 + exit 1 + fi + exit 0 + ;; + rm) + echo "rm called" >> "$LOG_FILE" + exit 0 + ;; +esac + +exit 0 +BASH; + } +} + +class ScriptDockerStreamingTestHarness +{ + use ScriptDockerStreamingFilesTrait; + use ScriptDockerCopyingFilesTrait; + + public function executeStreamingMode(array $options): array + { + return $this->executeStreaming($options); + } + + public function imageSupportsStreamingMode(string $image): bool + { + return $this->imageSupportsStreaming($image); + } + + public function executeCopyingMode(array $options): array + { + return $this->executeCopying($options); + } +} diff --git a/tests/unit/ProcessMaker/Models/ScriptDockerStreamingFilesTraitTest.php b/tests/unit/ProcessMaker/Models/ScriptDockerStreamingFilesTraitTest.php new file mode 100644 index 0000000000..6beac9727e --- /dev/null +++ b/tests/unit/ProcessMaker/Models/ScriptDockerStreamingFilesTraitTest.php @@ -0,0 +1,99 @@ +harness = new ScriptDockerStreamingTestHarness(); + } + + public function testBuildUstarTarCreatesValidArchive(): void + { + $inputs = [ + '/opt/executor/data.json' => '{"foo":"bar"}', + '/opt/executor/config.json' => '{"key":"value"}', + '/opt/executor/script.php' => ' true];', + ]; + + $tar = $this->harness->buildTar($inputs); + $parsed = $this->harness->parseTar($tar); + + foreach ($inputs as $path => $content) { + $normalized = ltrim($path, '/'); + $this->assertArrayHasKey($normalized, $parsed); + $this->assertSame($content, $parsed[$normalized]); + } + } + + public function testBuildUstarTarHandlesLongPaths(): void + { + $longPath = '/opt/executor/' . str_repeat('nested/', 20) . 'data.json'; + $content = '{"long":"path"}'; + $inputs = [$longPath => $content]; + + $tar = $this->harness->buildTar($inputs); + $parsed = $this->harness->parseTar($tar); + + $this->assertSame($content, $parsed[ltrim($longPath, '/')]); + } + + public function testValidateUstarTarThrowsOnMissingFile(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Tar validation failed'); + + $this->harness->validateTar(str_repeat("\0", 1024), [ + '/opt/executor/missing.json' => '{}', + ]); + } + + public function testParseUstarTarReturnsEmptyForBlankArchive(): void + { + $this->assertSame([], $this->harness->parseTar(str_repeat("\0", 1024))); + } +} + +class ScriptDockerStreamingTestHarness +{ + use ScriptDockerStreamingFilesTrait; + use ScriptDockerCopyingFilesTrait; + + public function buildTar(array $inputs): string + { + return $this->buildUstarTar($inputs); + } + + public function parseTar(string $tar): array + { + return $this->parseUstarTar($tar); + } + + public function validateTar(string $tar, array $expected): void + { + $this->validateUstarTar($tar, $expected); + } + + public function executeStreamingMode(array $options): array + { + return $this->executeStreaming($options); + } + + public function imageSupportsStreamingMode(string $image): bool + { + return $this->imageSupportsStreaming($image); + } + + public function executeCopyingMode(array $options): array + { + return $this->executeCopying($options); + } +}