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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,8 @@ return [
'enabled' => env('WAYFINDER_CACHE_ENABLED', true),
'directory' => env('WAYFINDER_CACHE_DIRECTORY', storage_path('wayfinder-cache')),
],

'memory_limit' => env('WAYFINDER_MEMORY_LIMIT'),
];
```

Expand All @@ -878,6 +880,13 @@ return [
| `format.enabled` | Format generated files with Biome | `false` |
| `cache.enabled` | Enable caching for faster regeneration | `true` |
| `cache.directory` | Directory for cache files | `storage/wayfinder-cache` |
| `memory_limit` | What to run generation under | `null` |

Generation analyzes your application's class graph, which takes more memory than
PHP's 128M default, so `wayfinder:generate` raises `memory_limit` to `1536M` when
it finds it set lower. Set `memory_limit` to say what to use instead, in
`php.ini`'s format (`'768M'`, `'2G'`, or `'-1'` for no limit). Wayfinder takes it
as given, higher or lower.

## Syncing Across Repositories

Expand Down
2 changes: 2 additions & 0 deletions config/wayfinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@
'enabled' => env('WAYFINDER_CACHE_ENABLED', true),
'directory' => env('WAYFINDER_CACHE_DIRECTORY', storage_path('wayfinder-cache')),
],

'memory_limit' => env('WAYFINDER_MEMORY_LIMIT'),
];
42 changes: 42 additions & 0 deletions src/Console/GenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@
use function Illuminate\Filesystem\join_paths;
use function Laravel\Prompts\info;
use function Laravel\Prompts\progress;
use function Laravel\Prompts\warning;

class GenerateCommand extends Command
{
protected const MEMORY_LIMIT = '1536M';

protected $signature = 'wayfinder:generate {--path=} {--base-path=} {--app-path=} {--fresh}';

protected $description = 'Generate TypeScript files for your Laravel application';
Expand Down Expand Up @@ -61,6 +64,8 @@ public function handle(
Enums $enumConverter,
Routes $routesConverter,
) {
$this->raiseMemoryLimit();

$cacheDirectory = $this->config->get('wayfinder.cache.directory');

AnalyzedCache::freezeFileTimes();
Expand Down Expand Up @@ -156,6 +161,43 @@ public function handle(
$this->writeFiles();
}

protected function raiseMemoryLimit(): void
{
$configured = $this->config->get('wayfinder.memory_limit');

if (! in_array($configured, [null, ''])) {
if (@ini_set('memory_limit', (string) $configured) !== false) {
return;
}

warning("PHP would not take the configured memory limit [{$configured}], using ".self::MEMORY_LIMIT.' instead.');
}

$current = $this->toBytes((string) ini_get('memory_limit'));

// A negative limit is already uncapped. Anything else that does not
// read as a size is a limit to leave alone rather than guess at.
if ($current <= 0 || $current >= $this->toBytes(self::MEMORY_LIMIT)) {
return;
}

// Environments that forbid changing the limit keep the one they have.
@ini_set('memory_limit', self::MEMORY_LIMIT);
}

protected function toBytes(string $limit): int
{
$limit = trim($limit);
$value = (int) $limit;

return match (strtoupper(substr($limit, -1))) {
'G' => $value * 1024 ** 3,
'M' => $value * 1024 ** 2,
'K' => $value * 1024,
default => $value,
};
}

/**
* Tell the analyzer which attributes and comment tags mean "leave this
* out", and fold them into the cache key: they decide what ends up in the
Expand Down
125 changes: 120 additions & 5 deletions tests/Feature/GenerateCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Tests\Feature;

use Illuminate\Config\Repository;
use Illuminate\Filesystem\Filesystem;
use Laravel\Wayfinder\Console\GenerateCommand;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Process;

Expand Down Expand Up @@ -40,22 +42,40 @@ protected function tearDown(): void
}

private function generate(): void
{
$process = $this->runGenerate();

$this->assertTrue(
$process->isSuccessful(),
'wayfinder:generate failed: '.$this->outputOf($process)
);
}

/**
* @param list<string> $phpArgs Passed to PHP itself, ahead of the script.
* @param array<string, string> $env Added to the environment it inherits.
*/
private function runGenerate(array $phpArgs = [], array $env = []): Process
{
$process = new Process([
PHP_BINARY,
...$phpArgs,
join_paths($this->rootPath, 'vendor', 'bin', 'testbench'),
'wayfinder:generate',
'--path='.$this->tempPath,
'--app-path='.join_paths($this->rootPath, 'workbench', 'app'),
'--base-path='.join_paths($this->rootPath, 'workbench'),
], $this->rootPath, ['WAYFINDER_CACHE_ENABLED' => 'false']);
], $this->rootPath, ['WAYFINDER_CACHE_ENABLED' => 'false', ...$env]);

$process->setTimeout(60);
$process->run();

$this->assertTrue(
$process->isSuccessful(),
'wayfinder:generate failed: '.$process->getErrorOutput().$process->getOutput()
);
return $process;
}

private function outputOf(Process $process): string
{
return $process->getErrorOutput().$process->getOutput();
}

public function test_generated_files_exist_after_generate(): void
Expand Down Expand Up @@ -136,6 +156,101 @@ public function test_unchanged_generated_files_are_not_rewritten(): void
$this->assertSame($beforeMtime, filemtime($sample));
}

public function test_generate_completes_under_a_low_memory_limit(): void
{
// A cold cache needs more than PHP's 128M default (laravel/wayfinder#167).
// 64M rather than 128M so the two ways this can stop testing anything
// stay far off: booting far enough to raise the limit takes ~32M, and
// an unraised run needs ~160M.
$process = $this->runGenerate(['-d', 'memory_limit=64M']);

$this->assertTrue(
$process->isSuccessful(),
'wayfinder:generate failed under a 64M memory limit: '.$this->outputOf($process)
);
$this->assertFileExists(join_paths($this->tempPath, 'index.ts'));
}

public function test_a_capped_memory_limit_is_raised_to_a_bound(): void
{
// Not removed: a runaway analysis should still stop with a PHP error
// rather than being killed by the OS without one.
$this->assertSame('1536M', $this->limitAfterRaising('256M'));
}

public function test_a_memory_limit_above_the_bound_is_left_alone(): void
{
$this->assertSame('2048M', $this->limitAfterRaising('2048M'));
}

public function test_an_uncapped_memory_limit_is_left_alone(): void
{
$this->assertSame('-1', $this->limitAfterRaising('-1'));
}

public function test_a_configured_limit_is_used_above_the_bound(): void
{
$this->assertSame('4096M', $this->limitAfterRaising('256M', '4096M'));
}

public function test_a_configured_limit_is_used_below_the_bound(): void
{
// Someone who has named a limit has overridden the bound, not asked to
// be raised to it.
$this->assertSame('256M', $this->limitAfterRaising('512M', '256M'));
}

public function test_a_configured_limit_can_remove_the_cap(): void
{
$this->assertSame('-1', $this->limitAfterRaising('256M', '-1'));
}

public function test_a_configured_limit_php_rejects_falls_back_to_the_bound(): void
{
$this->assertSame('1536M', $this->limitAfterRaising('256M', 'not-a-size'));
}

public function test_a_rejected_configured_limit_is_reported_and_generation_continues(): void
{
$process = $this->runGenerate(
['-d', 'memory_limit=128M'],
['WAYFINDER_MEMORY_LIMIT' => 'not-a-size'],
);

$output = $this->outputOf($process);

$this->assertTrue($process->isSuccessful(), 'wayfinder:generate failed: '.$output);
$this->assertStringContainsString('not-a-size', $output);
$this->assertFileExists(join_paths($this->tempPath, 'index.ts'));
}

/**
* Run raiseMemoryLimit() against a starting limit, optionally with one
* configured, and report where it left it. Every value has to be above
* what the suite is already using, or PHP refuses it.
*/
private function limitAfterRaising(string $start, ?string $configured = null): string
{
$original = ini_get('memory_limit');

try {
ini_set('memory_limit', $start);

$command = (new \ReflectionClass(GenerateCommand::class))->newInstanceWithoutConstructor();

(new \ReflectionProperty($command, 'config'))->setValue(
$command,
new Repository(['wayfinder' => ['memory_limit' => $configured]]),
);

(new \ReflectionMethod($command, 'raiseMemoryLimit'))->invoke($command);

return (string) ini_get('memory_limit');
} finally {
ini_set('memory_limit', $original);
}
}

public function test_noop_regenerate_does_not_touch_any_file(): void
{
$this->generate();
Expand Down