Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified builds/cpx
Binary file not shown.
41 changes: 39 additions & 2 deletions src/Composer/ComposerRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Cpx\Process\ProcessResult;
use Cpx\Process\ProcessRunner;
use Cpx\Runtime\Environment;
use Cpx\Support\Str;
use RuntimeException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\ArgvInput;
Expand All @@ -21,6 +22,8 @@ class ComposerRunner
{
public const REINVOKE_TOKEN = '__cpx_run_composer';

private const MINIMUM_MEMORY_LIMIT = '1536M';

/** @var (Closure(list<string>): (int|ProcessResult))|null */
private static ?Closure $fake = null;

Expand All @@ -37,7 +40,7 @@ public static function require(string $package, string $directory): int
return $result->exitCode;
}

$exception = new ComposerCommandException($arguments);
$exception = new ComposerCommandException($arguments, $result->output);
$missingPackage = self::missingPackage($result->output);

if ($missingPackage === null) {
Expand Down Expand Up @@ -71,6 +74,8 @@ public static function run(array $arguments, ?string $directory = null): void
*/
public static function runInProcess(array $arguments, ?OutputInterface $output = null): int
{
self::raiseMemoryLimit();

$composer = new ComposerApplication;
$composer->setAutoExit(false);

Expand Down Expand Up @@ -170,9 +175,41 @@ private static function execute(array $arguments, ?string $directory, bool $capt
: new ProcessResult($runner->run($processCommand), '');
}

/** Mirrors the bin/composer bootstrap, which booting Composer in-process bypasses. */
private static function raiseMemoryLimit(): void
{
if (! function_exists('ini_set')) {
return;
}

if ($override = getenv('COMPOSER_MEMORY_LIMIT')) {
@ini_set('memory_limit', $override);

return;
}

$current = trim((string) ini_get('memory_limit'));

if ($current !== '-1' && self::memoryInBytes($current) < self::memoryInBytes(self::MINIMUM_MEMORY_LIMIT)) {
@ini_set('memory_limit', self::MINIMUM_MEMORY_LIMIT);
}
}

private static function memoryInBytes(string $value): int
{
$bytes = (int) $value;

return match (strtolower(substr($value, -1))) {
'g' => $bytes * 1024 * 1024 * 1024,
'm' => $bytes * 1024 * 1024,
'k' => $bytes * 1024,
default => $bytes,
};
}

private static function missingPackage(string $output): ?string
{
$output = preg_replace('/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -\/]*[@-~])/', '', $output) ?? $output;
$output = Str::stripAnsi($output);
$output = preg_replace('/\s+/', ' ', $output) ?? $output;
$package = '(?<package>[a-z0-9](?:[_.-]?[a-z0-9]+)*\/[a-z0-9](?:(?:[_.]?|-{0,2})[a-z0-9]+)*)';

Expand Down
27 changes: 25 additions & 2 deletions src/Exceptions/ComposerCommandException.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,38 @@

namespace Cpx\Exceptions;

use Cpx\Support\Str;
use Exception;

class ComposerCommandException extends Exception
{
private const OUTPUT_LIMIT = 4000;

/**
* @param list<string> $arguments
*/
public function __construct(array $arguments)
public function __construct(array $arguments, string $output = '')
{
parent::__construct('Composer command failed: '.implode(' ', $arguments));
$message = 'Composer command failed: '.implode(' ', $arguments);
$diagnostic = self::diagnostic($output);

if ($diagnostic !== '') {
$message .= "\n\n{$diagnostic}";
}

parent::__construct($message);
}

private static function diagnostic(string $output): string
{
$output = trim(Str::stripAnsi($output));

if (strlen($output) <= self::OUTPUT_LIMIT) {
return $output;
}

$half = intdiv(self::OUTPUT_LIMIT, 2);

return substr($output, 0, $half)."\n... [output truncated] ...\n".substr($output, -$half);
}
}
13 changes: 13 additions & 0 deletions src/Support/Str.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Cpx\Support;

class Str
{
public static function stripAnsi(string $value): string
{
return preg_replace('/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -\/]*[@-~])/', '', $value) ?? $value;
}
}
106 changes: 106 additions & 0 deletions tests/Unit/ComposerRunnerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,77 @@
'unknown failure' => ['Composer encountered an unexpected error.'],
])->throws(ComposerCommandException::class, 'Composer command failed: require vendor/package:^9.0');

test('it surfaces the composer diagnostic when a require fails for another reason', function (string $diagnostic) {
ComposerRunner::fake(fn (array $command): ProcessResult => new ProcessResult(1, $diagnostic));

try {
ComposerRunner::require('vendor/package:^9.0', '/tmp/example');
} catch (ComposerCommandException $exception) {
expect($exception->getMessage())
->toContain('Composer command failed: require vendor/package:^9.0')
->toContain($diagnostic);

return;
}

$this->fail('Expected ComposerCommandException to be thrown.');
})->with([
'transport failure' => ['curl error 60: SSL certificate problem'],
'memory exhaustion' => ['Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 8192 bytes) in phar:///cpx/vendor/composer/composer/src/Composer/DependencyResolver/PoolOptimizer.php on line 230'],
]);

test('it strips ansi escape codes from the surfaced diagnostic', function () {
ComposerRunner::fake(fn (array $command): ProcessResult => new ProcessResult(1, "\e[31mcurl error 60\e[39m: SSL certificate problem"));

try {
ComposerRunner::require('vendor/package', '/tmp/example');
} catch (ComposerCommandException $exception) {
expect($exception->getMessage())
->toContain('curl error 60: SSL certificate problem')
->not->toContain("\e[");

return;
}

$this->fail('Expected ComposerCommandException to be thrown.');
});

test('it truncates a long diagnostic while keeping the start and the end', function () {
$start = 'Fatal error: Allowed memory size of 134217728 bytes exhausted';
$end = 'In Solver.php line 221: memory exhausted';
$trace = str_repeat("#0 phar:///cpx/vendor/composer/composer/src/Composer/DependencyResolver/Solver.php(221): solve()\n", 200);

ComposerRunner::fake(fn (array $command): ProcessResult => new ProcessResult(1, "{$start}\n{$trace}{$end}"));

try {
ComposerRunner::require('vendor/package', '/tmp/example');
} catch (ComposerCommandException $exception) {
expect($exception->getMessage())
->toContain($start)
->toContain($end)
->toContain('truncated')
->and(strlen($exception->getMessage()))->toBeLessThan(5000);

return;
}

$this->fail('Expected ComposerCommandException to be thrown.');
});

test('a require failure with no captured output keeps the one-line message', function () {
ComposerRunner::fake(fn (array $command): ProcessResult => new ProcessResult(1, " \n "));

try {
ComposerRunner::require('vendor/package', '/tmp/example');
} catch (ComposerCommandException $exception) {
expect($exception->getMessage())->toBe('Composer command failed: require vendor/package');

return;
}

$this->fail('Expected ComposerCommandException to be thrown.');
});

test('generic composer commands do not classify missing-package output', function () {
ComposerRunner::fake(fn (array $command): ProcessResult => new ProcessResult(1, 'Could not find a matching version of package vendor/missing.'));

Expand Down Expand Up @@ -217,6 +288,41 @@
expect(ComposerRunner::runInProcess(['this-command-does-not-exist', '--quiet'], new BufferedOutput))->toBe(1);
});

test('it raises a low memory limit before booting composer in-process', function (string $current, string $expected) {
$this->useIsolatedComposerHome();
$this->setEnvironmentVariable('COMPOSER_MEMORY_LIMIT', '');
$original = (string) ini_get('memory_limit');

try {
ini_set('memory_limit', $current);
ComposerRunner::runInProcess(['about', '--quiet'], new BufferedOutput);

expect(ini_get('memory_limit'))->toBe($expected);
} finally {
ini_set('memory_limit', $original);
}
})->with([
'low megabytes' => ['900M', '1536M'],
'low bytes' => ['943718400', '1536M'],
'unlimited' => ['-1', '-1'],
'already high' => ['3G', '3G'],
]);

test('it applies an explicit composer memory limit override verbatim', function () {
$this->useIsolatedComposerHome();
$this->setEnvironmentVariable('COMPOSER_MEMORY_LIMIT', '2G');
$original = (string) ini_get('memory_limit');

try {
ini_set('memory_limit', '3G');
ComposerRunner::runInProcess(['about', '--quiet'], new BufferedOutput);

expect(ini_get('memory_limit'))->toBe('2G');
} finally {
ini_set('memory_limit', $original);
}
});

test('it runs an offline composer command in an isolated child process', function () {
$this->useIsolatedComposerHome();

Expand Down
Loading