From 8b3f93e7db58e4127324e8eb54a7456e0c52d320 Mon Sep 17 00:00:00 2001 From: Franco Gilio Date: Sun, 6 Sep 2026 12:28:34 +0100 Subject: [PATCH 1/4] feat(cli): add rfa:optimize for cache rebuilds beside a running server `optimize` empties the compiled-views directory through view:cache before recompiling, which a request served in the meantime can trip over. The new command runs `optimize --except=views` and then compiles every Blade template in place through CompileViewsAction: Blade writes each compiled file with an atomic rename and skips unchanged output, so the live directory is never cleared. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RqwYa86be82YSKkJYb9VLz --- app/Actions/CompileViewsAction.php | 84 ++++++++++++++++ app/Console/Commands/OptimizeCommand.php | 70 ++++++++++++++ tests/Feature/Console/OptimizeCommandTest.php | 58 +++++++++++ tests/Unit/Actions/CompileViewsActionTest.php | 95 +++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 app/Actions/CompileViewsAction.php create mode 100644 app/Console/Commands/OptimizeCommand.php create mode 100644 tests/Feature/Console/OptimizeCommandTest.php create mode 100644 tests/Unit/Actions/CompileViewsActionTest.php diff --git a/app/Actions/CompileViewsAction.php b/app/Actions/CompileViewsAction.php new file mode 100644 index 00000000..5b13dc3f --- /dev/null +++ b/app/Actions/CompileViewsAction.php @@ -0,0 +1,84 @@ +paths(); + + $compiled = $paths + ->flatMap(fn (string $path): Collection => $this->bladeFilesIn($path)) + ->each(fn (SplFileInfo $file) => $this->compiler->compile($file->getRealPath())) + ->count(); + + return ['paths' => $paths->count(), 'compiled' => $compiled]; + } + + /** + * The view roots and namespace hints, with roots nested inside another + * root dropped so no template is compiled twice. + * + * @return Collection + */ + private function paths(): Collection + { + $finder = $this->views->getFinder(); + + if (! $finder instanceof FileViewFinder) { + return collect(); + } + + $paths = collect($finder->getPaths()) + ->merge(collect($finder->getHints())->flatten()) + ->filter(fn (mixed $path): bool => is_string($path)) + ->unique() + ->values(); + + $directory = fn (string $path): string => rtrim(realpath($path) ?: $path, '/').'/'; + + return $paths + ->reject(fn (string $path): bool => $paths->contains( + fn (string $existing): bool => $existing !== $path && str_starts_with($directory($path), $directory($existing)), + )) + ->values(); + } + + /** @return Collection */ + private function bladeFilesIn(string $path): Collection + { + if (! is_dir($path)) { + return collect(); + } + + return collect(iterator_to_array( + Finder::create()->in($path)->exclude('vendor')->name('*.blade.php')->files(), + false, + )); + } +} diff --git a/app/Console/Commands/OptimizeCommand.php b/app/Console/Commands/OptimizeCommand.php new file mode 100644 index 00000000..cb9eaa2c --- /dev/null +++ b/app/Console/Commands/OptimizeCommand.php @@ -0,0 +1,70 @@ +call('optimize', ['--except' => 'views']); + + if ($status !== self::SUCCESS) { + $outcome = 'error'; + Context::add('rfa.reason', 'optimize_failed'); + + return $status; + } + + $views = $compileViews->handle(); + + Context::add('rfa.view_path_count', $views['paths']); + Context::add('rfa.view_count', $views['compiled']); + + $this->components->info(sprintf('Compiled %d Blade templates from %d view roots.', $views['compiled'], $views['paths'])); + + return self::SUCCESS; + } catch (Throwable $e) { + $outcome = 'error'; + $status = self::FAILURE; + Context::add('rfa.error_class', $e::class); + Context::add('rfa.reason', 'view_compile_failed'); + + throw $e; + } finally { + Context::add('rfa.optimize_status', $status); + Context::add('rfa.outcome', $outcome); + Context::add('rfa.duration_ms', (int) round((microtime(true) - $startedAt) * 1000)); + + Log::info('framework.optimized'); + } + } +} diff --git a/tests/Feature/Console/OptimizeCommandTest.php b/tests/Feature/Console/OptimizeCommandTest.php new file mode 100644 index 00000000..5a925567 --- /dev/null +++ b/tests/Feature/Console/OptimizeCommandTest.php @@ -0,0 +1,58 @@ +stagingDir = sys_get_temp_dir().'/rfa_test_optimize_'.getmypid().'_'.uniqid('', true); + File::ensureDirectoryExists($this->stagingDir); + + $this->cacheEnv = [ + 'APP_CONFIG_CACHE' => $this->stagingDir.'/config.php', + 'APP_ROUTES_CACHE' => $this->stagingDir.'/routes-v7.php', + 'APP_EVENTS_CACHE' => $this->stagingDir.'/events.php', + ]; + + foreach ($this->cacheEnv as $key => $path) { + $_SERVER[$key] = $path; + $_ENV[$key] = $path; + } +}); + +afterEach(function () { + foreach (array_keys($this->cacheEnv) as $key) { + unset($_SERVER[$key], $_ENV[$key]); + } + + File::deleteDirectory($this->stagingDir); +}); + +test('rfa:optimize writes the config, route, and event caches where the environment points and compiles the views', function () { + $compiledDir = (string) config('view.compiled'); + $sentinel = $compiledDir.'/rfa-test-live-request.php'; + File::ensureDirectoryExists($compiledDir); + File::put($sentinel, 'artisan('rfa:optimize') + ->expectsOutputToContain('Compiled') + ->assertSuccessful(); + + expect(File::exists($this->cacheEnv['APP_CONFIG_CACHE']))->toBeTrue() + ->and(File::exists($this->cacheEnv['APP_ROUTES_CACHE']))->toBeTrue() + ->and(File::exists($this->cacheEnv['APP_EVENTS_CACHE']))->toBeTrue() + ->and(File::get($sentinel))->toBe('and(File::exists(app('blade.compiler')->getCompiledPath(resource_path('views/components/empty-state.blade.php'))))->toBeTrue(); + } finally { + File::delete($sentinel); + } +}); diff --git a/tests/Unit/Actions/CompileViewsActionTest.php b/tests/Unit/Actions/CompileViewsActionTest.php new file mode 100644 index 00000000..03d00352 --- /dev/null +++ b/tests/Unit/Actions/CompileViewsActionTest.php @@ -0,0 +1,95 @@ +root = (string) realpath(sys_get_temp_dir()).'/rfa_test_compile_views_'.getmypid().'_'.uniqid('', true); + $this->viewsDir = $this->root.'/views'; + $this->packageDir = $this->root.'/package-views'; + $this->compiledDir = $this->root.'/compiled'; + + File::ensureDirectoryExists($this->viewsDir.'/nested'); + File::ensureDirectoryExists($this->viewsDir.'/vendor/skipped'); + File::ensureDirectoryExists($this->packageDir); + File::ensureDirectoryExists($this->compiledDir); + + File::put($this->viewsDir.'/home.blade.php', '

{{ $greeting }}

'); + File::put($this->viewsDir.'/nested/panel.blade.php', '@if($open)
open
@endif'); + File::put($this->viewsDir.'/plain.php', 'viewsDir.'/vendor/skipped/published.blade.php', '

published

'); + File::put($this->packageDir.'/widget.blade.php', '{{ $label }}'); + + $files = new Filesystem; + $finder = new FileViewFinder($files, [$this->viewsDir]); + $finder->addNamespace('package', $this->packageDir); + $finder->addNamespace('nested-again', $this->viewsDir.'/nested'); + + $this->compiler = new BladeCompiler($files, $this->compiledDir); + $this->action = new CompileViewsAction(new Factory(new EngineResolver, $finder, new Dispatcher), $this->compiler); +}); + +afterEach(function () { + File::deleteDirectory($this->root); +}); + +test('compiles every blade template under the view roots and namespace hints', function () { + $result = $this->action->handle(); + + expect($result)->toBe(['paths' => 2, 'compiled' => 3]) + ->and(File::exists($this->compiler->getCompiledPath($this->viewsDir.'/home.blade.php')))->toBeTrue() + ->and(File::exists($this->compiler->getCompiledPath($this->viewsDir.'/nested/panel.blade.php')))->toBeTrue() + ->and(File::exists($this->compiler->getCompiledPath($this->packageDir.'/widget.blade.php')))->toBeTrue() + ->and(File::get($this->compiler->getCompiledPath($this->viewsDir.'/home.blade.php')))->toContain(''); +}); + +test('leaves files already in the compiled directory in place', function () { + File::put($this->compiledDir.'/live-request.php', 'action->handle(); + + expect(File::get($this->compiledDir.'/live-request.php'))->toBe('action->handle(); + + expect(File::exists($this->compiler->getCompiledPath($this->viewsDir.'/vendor/skipped/published.blade.php')))->toBeFalse() + ->and(File::exists($this->compiler->getCompiledPath($this->viewsDir.'/plain.php')))->toBeFalse(); +}); + +test('a view root that does not exist compiles nothing instead of failing', function () { + $files = new Filesystem; + $action = new CompileViewsAction( + new Factory(new EngineResolver, new FileViewFinder($files, [$this->root.'/missing']), new Dispatcher), + new BladeCompiler($files, $this->compiledDir), + ); + + expect($action->handle())->toBe(['paths' => 1, 'compiled' => 0]); +}); + +test('a sibling root sharing a name prefix is not mistaken for a nested root', function () { + $sibling = $this->root.'/views-extra'; + File::ensureDirectoryExists($sibling); + File::put($sibling.'/extra.blade.php', 'extra'); + + $files = new Filesystem; + $action = new CompileViewsAction( + new Factory(new EngineResolver, new FileViewFinder($files, [$this->viewsDir, $sibling]), new Dispatcher), + $this->compiler, + ); + + expect($action->handle())->toBe(['paths' => 2, 'compiled' => 3]) + ->and(File::exists($this->compiler->getCompiledPath($sibling.'/extra.blade.php')))->toBeTrue(); +}); From 8bc5eaebca59989ce5d4ed69b867a3f1270dedc1 Mon Sep 17 00:00:00 2001 From: Franco Gilio Date: Sun, 6 Sep 2026 12:28:36 +0100 Subject: [PATCH 2/4] perf(native): rebuild the framework caches in the background After a version change the optimize used to run synchronously before the PHP server spawned and held the launch for about 1.7 s. The server now starts right away and rfa:optimize runs as a background child with the config, route, and event cache paths pointed at a staging directory. The three files are renamed into place once the child exits cleanly, so no request ever requires a torn cache file. Caches left by the previous version are removed before the server spawns, the child is killed with the app, and the optimized version is stamped only after the rename. The previous synchronous block is upgraded in place, and the launch timeline marks for the optimize move into the background runner. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RqwYa86be82YSKkJYb9VLz --- scripts/patch-nativephp.php | 222 +++++++++++++++------ tests/Helpers/native-php-dist-fixtures.php | 2 + tests/Unit/Scripts/PatchNativePhpTest.php | 177 ++++++++++------ 3 files changed, 273 insertions(+), 128 deletions(-) diff --git a/scripts/patch-nativephp.php b/scripts/patch-nativephp.php index f7fa4c4a..e0ce25a7 100644 --- a/scripts/patch-nativephp.php +++ b/scripts/patch-nativephp.php @@ -468,13 +468,17 @@ function rfaPatchRendererReadyWindow(string $content): ?string * `electron-vite build` bundles directly — it does not run the plugin's `tsc` * step): * - * 1. Optimize once per version, then skip the cache step on warm launches. - * NativePHP runs `php artisan optimize` synchronously before the PHP server - * starts, on every launch. That recompiles all Blade views and re-caches - * config/routes/events (~1s) and blocks the window. The compiled caches - * persist in the build's bootstrap/cache, so the full optimize is only - * needed on a version change (fresh install / post-update) or when a cache - * file is missing. On same-version launches the cache step is skipped + * 1. Optimize once per version, in the background, and skip the cache step + * on warm launches. NativePHP runs `php artisan optimize` synchronously + * before the PHP server starts, on every launch. That recompiles all Blade + * views and re-caches config/routes/events (~1.7s) and blocks the window. + * The compiled caches persist in the build's bootstrap/cache, so the + * rebuild is only needed on a version change (fresh install / post-update) + * or when a cache file is missing, and then it runs as `rfa:optimize` in a + * background child while the server serves from source: the three cache + * files are written to a staging directory and renamed into place so no + * request ever reads a torn file, and views are compiled without clearing + * the live directory. On same-version launches the cache step is skipped * ENTIRELY: the only per-launch-varying config (the native API port and IPC * secret) is re-read from the live process environment at runtime by * RehydrateNativeRuntimeConfigAction, so the persisted config stays valid @@ -489,8 +493,8 @@ function rfaPatchRendererReadyWindow(string $content): ?string * (The long-lived server and the optimize/migrate calls already get opcache * via NativeAppServiceProvider::phpIni().) * - * Both edits must land: this returns null unless every one of them is present - * in the result, so a NativePHP bump that reshapes one block fails the patch + * Every edit must land: this returns null unless each of them is present in + * the result, so a NativePHP bump that reshapes one block fails the patch * set rather than shipping a half-optimized bootstrap. * * @return string|null the patched content, or null when the expected source @@ -512,6 +516,53 @@ function rfaPatchServerOptimize(string $content): ?string JS; $optimizeReplace = <<<'JS' + if (shouldOptimize(store)) { + // [rfa patch] `php artisan optimize` recompiles every Blade view and + // re-caches config/routes/events (~1.7s). Stock NativePHP ran it on + // every launch, blocking the window. The compiled caches persist in + // the build's bootstrap/cache, so it is only needed when the app + // version changes (fresh install / post-update) or a cache file is + // missing, and then it runs in the background while the PHP server + // starts and serves: the framework boots from source until the + // caches land, a few ms per request, instead of holding the window + // for the whole optimize. + // + // On a same-version launch the cache step is skipped ENTIRELY, + // including config:cache for the fresh per-launch API port and IPC + // secret: the app re-reads those two values from the live process + // environment at runtime (RehydrateNativeRuntimeConfigAction, wired + // in bootstrap/app.php via a beforeBootstrapping(RegisterProviders) + // hook that runs before any provider registers), so the persisted + // version-cached config stays valid. + // + // Probe the caches at the directory Laravel actually writes them to + // for this build type. NativePHP only redirects APP_*_CACHE into + // userData/bootstrap/cache for a *secure* build; an unsecure build + // (what `native:build` produces without a bundle, RFA's shipping + // shape) leaves them at /bootstrap/cache. Checking + // bootstrapCache unconditionally would never find them in an unsecure + // build, so the gate would trip every launch and pay the full optimize. + const rfaCacheDir = runningSecureBuild() ? bootstrapCache : join(getAppPath(), 'bootstrap', 'cache'); + const rfaVersionChanged = store.get('optimized_version') !== app.getVersion(); + const rfaNeedsFullOptimize = rfaVersionChanged + || !existsSync(join(rfaCacheDir, 'config.php')) + || !existsSync(join(rfaCacheDir, 'routes-v7.php')) + || !existsSync(join(rfaCacheDir, 'events.php')); + if (rfaNeedsFullOptimize) { + rfaOptimizeInBackground(rfaCacheDir, rfaVersionChanged, phpOptions, phpIniSettings, () => { + store.set('optimized_version', app.getVersion()); + }); + } + } +JS; + + // The optimize block as the PREVIOUS RFA revision left it: the same gate, + // but the optimize ran synchronously through callPhpSync and held the + // launch for the whole cache rebuild. The stock find above no longer + // matches such a file, so without this an already-patched vendor copy + // would keep blocking. Replacing the whole old block upgrades it to the + // current background shape, byte-identical to a fresh patch. + $oldOptimizeFind = <<<'JS' if (shouldOptimize(store)) { // [rfa patch] `php artisan optimize` recompiles every Blade view and // re-caches config/routes/events (~1s) and previously ran on every @@ -556,47 +607,81 @@ function rfaPatchServerOptimize(string $content): ?string } JS; - // The optimize block as the PREVIOUS RFA revision left it: a same-version - // launch re-ran `config:cache` via an rfaCommand ternary (no config.php - // probe). The stock find above no longer matches such a file, so without - // this an already-patched vendor copy would keep paying config:cache every - // warm launch. Replacing the whole old block upgrades it to the current - // skip-entirely shape, byte-identical to a fresh patch. - $oldOptimizeFind = <<<'JS' - if (shouldOptimize(store)) { - // [rfa patch] `php artisan optimize` recompiles every Blade view - // (~1s) and previously ran on every launch, blocking the window. - // Compiled views persist in userData and self-heal via on-demand - // compilation, so the full optimize is only needed when the app - // version changes (fresh install / post-update) or the route/event - // caches are missing. On same-version launches we re-cache config - // alone: NativePHP injects a fresh per-launch API port and secret - // that PHP reads through config(), so a reused config cache would - // point the PHP server at a stale port and break the native bridge. - // - // Probe the route/event caches at the directory Laravel actually - // writes them to for this build type. NativePHP only redirects - // APP_ROUTES_CACHE/APP_EVENTS_CACHE into userData/bootstrap/cache - // for a *secure* build; an unsecure build (what `native:build` - // produces without a bundle — RFA's shipping shape) leaves them at - // /bootstrap/cache. Checking bootstrapCache unconditionally - // would never find them in an unsecure build, so the gate would trip - // every launch and pay the full optimize anyway. - const rfaCacheDir = runningSecureBuild() ? bootstrapCache : join(getAppPath(), 'bootstrap', 'cache'); - const rfaVersionChanged = store.get('optimized_version') !== app.getVersion(); - const rfaNeedsFullOptimize = rfaVersionChanged - || !existsSync(join(rfaCacheDir, 'routes-v7.php')) - || !existsSync(join(rfaCacheDir, 'events.php')); - const rfaCommand = rfaNeedsFullOptimize ? 'optimize' : 'config:cache'; - console.log(rfaNeedsFullOptimize ? 'Caching views, routes, and config...' : 'Refreshing config cache...'); - let result = callPhpSync(['artisan', rfaCommand], phpOptions, phpIniSettings); - if (result.status !== 0) { - console.error('Failed to cache framework bootstrap:', result.stderr.toString()); + // The background runner lives next to serveApp() so the optimize block + // stays a gate and a call. It relies on php.js's own imports (fs_extra, + // mkdirpSync, join, app, callPhp) so no import line changes. + $helperFind = 'function serveApp(secret, apiPort, phpIniSettings) {'; + $helperMarker = 'function rfaOptimizeInBackground('; + $helperReplace = <<<'JS' +// [rfa patch] The framework caches, rebuilt without blocking the launch. +// +// Laravel writes config.php, routes-v7.php, and events.php with a plain +// file_put_contents and the server `require`s them on every request, so a +// request landing mid-write would parse a torn file. The child therefore +// writes them into a staging directory, and each is renamed into place with +// one atomic rename once the child exits cleanly. Compiled views go straight +// to the live directory: Blade writes those atomically and skips unchanged +// ones, and rfa:optimize never clears the directory the running server reads +// (view:cache does, which is why plain `optimize` is not used here). +// +// After a version change the caches left by the previous version are removed +// before the server spawns, so the new code never boots against them. +const rfaStagedCaches = { + APP_CONFIG_CACHE: 'config.php', + APP_ROUTES_CACHE: 'routes-v7.php', + APP_EVENTS_CACHE: 'events.php', +}; +function rfaOptimizeInBackground(cacheDir, versionChanged, phpOptions, phpIniSettings, onOptimized) { + const stagingDir = join(cacheDir, 'rfa-staging'); + const env = Object.assign({}, phpOptions.env); + try { + fs_extra.removeSync(stagingDir); + mkdirpSync(stagingDir); + Object.keys(rfaStagedCaches).forEach((key) => { + env[key] = join(stagingDir, rfaStagedCaches[key]); + if (versionChanged) { + fs_extra.removeSync(join(cacheDir, rfaStagedCaches[key])); } - else if (rfaNeedsFullOptimize) { - store.set('optimized_version', app.getVersion()); + }); + } + catch (error) { + console.error('Failed to prepare the framework cache staging directory:', error); + return; + } + console.log('Caching views, routes, and config in the background...'); + globalThis.__rfaLaunchMark?.('php.optimize.started'); + const child = callPhp(['artisan', 'rfa:optimize'], { cwd: phpOptions.cwd, env }, phpIniSettings); + let stderr = ''; + child.stdout.on('data', () => { }); + child.stderr.on('data', (data) => { stderr += data.toString(); }); + const stopWithApp = () => child.kill(); + app.once('before-quit', stopWithApp); + child.on('error', (error) => { + app.removeListener('before-quit', stopWithApp); + console.error('Failed to start the framework cache rebuild:', error); + }); + child.on('exit', (code) => { + app.removeListener('before-quit', stopWithApp); + globalThis.__rfaLaunchMark?.('php.optimize.finished'); + if (code !== 0) { + if (code !== null) { + console.error('Failed to cache framework bootstrap:', stderr); } + return; + } + try { + Object.values(rfaStagedCaches).forEach((file) => { + fs_extra.renameSync(join(stagingDir, file), join(cacheDir, file)); + }); + fs_extra.removeSync(stagingDir); + onOptimized(); } + catch (error) { + console.error('Failed to install the framework caches:', error); + } + }); +} +function serveApp(secret, apiPort, phpIniSettings) { JS; // Create the opcache file-cache directory at module load, before any PHP @@ -637,6 +722,10 @@ function rfaPatchServerOptimize(string $content): ?string $patched = str_replace($oldOptimizeFind, $optimizeReplace, $patched); } + if (str_contains($patched, $helperFind) && ! str_contains($patched, $helperMarker)) { + $patched = str_replace($helperFind, $helperReplace, $patched); + } + if (str_contains($patched, $mkdirFind) && ! str_contains($patched, '[rfa opcache] persistent opcode cache dir')) { $patched = str_replace($mkdirFind, $mkdirReplace, $patched); } @@ -646,18 +735,20 @@ function rfaPatchServerOptimize(string $content): ?string $patched = str_replace($preflightFind, $preflightReplace, $patched); } - // Only report success when every edit is present in the result. The config.php - // probe is the marker UNIQUE to the current skip-entirely shape — requiring it - // (not just `rfaNeedsFullOptimize`, which the previous revision also had) means - // a file still carrying the old config:cache warm-launch branch is treated as - // not-yet-patched rather than mis-reported as already_patched. The two opcache - // markers are each UNIQUE to their edit: the mkdir comment proves the cache - // directory is created, and the pre-flight banner (×2) proves both retrieve* - // helpers reuse it. (`'framework', 'opcache'` alone would be ambiguous — the - // pre-flight `opcache.file_cache=…` path contains that same substring, so a - // file with only the pre-flight edit could mis-report as fully patched while + // Only report success when every edit is present in the result. The + // background call is the marker UNIQUE to the current shape: requiring it + // (not just `rfaNeedsFullOptimize` or the config.php probe, which the + // previous revision also had) means a file still carrying the synchronous + // callPhpSync optimize is treated as not-yet-patched rather than + // mis-reported as already_patched. The two opcache markers are each UNIQUE + // to their edit: the mkdir comment proves the cache directory is created, + // and the pre-flight banner (x2) proves both retrieve* helpers reuse it. + // (`'framework', 'opcache'` alone would be ambiguous: the pre-flight + // `opcache.file_cache=...` path contains that same substring, so a file + // with only the pre-flight edit could mis-report as fully patched while // the cache directory is never created.) - $fullyPatched = str_contains($patched, "existsSync(join(rfaCacheDir, 'config.php'))") + $fullyPatched = str_contains($patched, 'rfaOptimizeInBackground(rfaCacheDir, rfaVersionChanged, phpOptions, phpIniSettings') + && str_contains($patched, $helperMarker) && str_contains($patched, 'rfaNeedsFullOptimize') && str_contains($patched, '[rfa opcache] persistent opcode cache dir') && substr_count($patched, '[rfa opcache] reuse compiled opcode') === 2; @@ -1615,6 +1706,7 @@ function rfaPatchLaunchTimeline(string $content): ?string marks: [], flushed: false, flushTimer: null, + deadline: Infinity, }; function rfaLaunchMark(name) { if (rfaLaunch.flushed) { @@ -1626,10 +1718,19 @@ function rfaLaunchMark(name) { rfaLaunch.flushTimer = setTimeout(rfaLaunchFlush, 1500); } } +function rfaLaunchMarked(name) { + return rfaLaunch.marks.some((mark) => mark.name === name); +} function rfaLaunchFlush() { if (rfaLaunch.flushed) { return; } + // A background cache rebuild outlives the presented window; hold the + // line for its finish mark, but never past the deadline. + if (Date.now() < rfaLaunch.deadline && rfaLaunchMarked('php.optimize.started') && !rfaLaunchMarked('php.optimize.finished')) { + rfaLaunch.flushTimer = setTimeout(rfaLaunchFlush, 500); + return; + } rfaLaunch.flushed = true; try { if (rfaLaunch.flushTimer !== null) { @@ -1699,6 +1800,7 @@ function rfaLaunchFlush() { NativePHP.prototype[method] = function (...args) { if (when === 'before') { rfaLaunchMark(mark); + rfaLaunch.deadline = Date.now() + 60000; rfaLaunch.flushTimer = setTimeout(rfaLaunchFlush, 60000); } const result = original.apply(this, args); @@ -1756,10 +1858,6 @@ function rfaLaunchFlush() { function rfaPatchLaunchTimelineServer(string $content): ?string { $edits = [ - [ - " let result = callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings);\n", - " globalThis.__rfaLaunchMark?.('php.optimize.started'); // [rfa launch timeline]\n let result = callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings);\n globalThis.__rfaLaunchMark?.('php.optimize.finished');\n", - ], [ " let result = callPhpSync(['artisan', 'migrate', '--force'], phpOptions, phpIniSettings);\n", " globalThis.__rfaLaunchMark?.('php.migrate.started'); // [rfa launch timeline]\n let result = callPhpSync(['artisan', 'migrate', '--force'], phpOptions, phpIniSettings);\n globalThis.__rfaLaunchMark?.('php.migrate.finished');\n", diff --git a/tests/Helpers/native-php-dist-fixtures.php b/tests/Helpers/native-php-dist-fixtures.php index c48c6ed8..860438fb 100644 --- a/tests/Helpers/native-php-dist-fixtures.php +++ b/tests/Helpers/native-php-dist-fixtures.php @@ -120,6 +120,8 @@ function retrieveNativePHPConfig() { return yield promisify(execFile)(state.php, command, phpOptions); }); } +function serveApp(secret, apiPort, phpIniSettings) { + return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { if (env.NIGHTWATCH_INGEST_URI && phpNightWatchPort) { console.log('Starting Nightwatch server...'); } diff --git a/tests/Unit/Scripts/PatchNativePhpTest.php b/tests/Unit/Scripts/PatchNativePhpTest.php index 846e33ee..0d4c845f 100644 --- a/tests/Unit/Scripts/PatchNativePhpTest.php +++ b/tests/Unit/Scripts/PatchNativePhpTest.php @@ -264,9 +264,10 @@ expect($content) ->toContain('[rfa patch]') ->toContain('const rfaNeedsFullOptimize') - // The full optimize only runs behind the version/cache gate; the warm - // path falls through with no cache step at all. + // The rebuild only runs behind the version/cache gate; the warm path + // falls through with no cache step at all. ->toContain('if (rfaNeedsFullOptimize) {') + ->toContain('rfaOptimizeInBackground(rfaCacheDir, rfaVersionChanged, phpOptions, phpIniSettings, () => {') // The cache dir is build-type aware: userData/bootstrap/cache for a // secure build, /bootstrap/cache for an unsecure one. Probing // bootstrapCache unconditionally would never trip the gate in an @@ -311,22 +312,95 @@ $content = rfaPatchServerOptimize(stockServer()); // The stock build ran `optimize` unconditionally on every launch. After - // patching the only `optimize` call lives inside `if (rfaNeedsFullOptimize)`, - // and the warm path runs no cache step at all — not even config:cache (which - // the previous patch revision still paid every launch). + // patching the only cache rebuild lives inside `if (rfaNeedsFullOptimize)`, + // and the warm path runs no cache step at all, not even config:cache. expect($content) ->toContain('if (rfaNeedsFullOptimize) {') - ->toContain("callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings)") + ->not->toContain("callPhpSync(['artisan', 'optimize']") ->not->toContain("'artisan', 'config:cache'") ->not->toContain('rfaCommand'); }); +test('the cache rebuild runs in the background through staged files', function () { + $content = (string) rfaPatchServerOptimize(stockServer()); + + expect($content) + ->toContain('function rfaOptimizeInBackground(cacheDir, versionChanged, phpOptions, phpIniSettings, onOptimized) {') + // rfa:optimize compiles views without clearing the live directory; + // plain optimize would empty it under the running server. + ->toContain("callPhp(['artisan', 'rfa:optimize'], { cwd: phpOptions.cwd, env }, phpIniSettings)") + // The three cache files Laravel writes non-atomically are staged... + ->toContain("APP_CONFIG_CACHE: 'config.php'") + ->toContain("APP_ROUTES_CACHE: 'routes-v7.php'") + ->toContain("APP_EVENTS_CACHE: 'events.php'") + ->toContain("const stagingDir = join(cacheDir, 'rfa-staging');") + ->toContain('env[key] = join(stagingDir, rfaStagedCaches[key]);') + // ...and renamed into place only after a clean exit. + ->toContain('fs_extra.renameSync(join(stagingDir, file), join(cacheDir, file));') + ->toContain('if (code !== 0) {') + // The previous version's caches never serve the new code. + ->toContain('if (versionChanged) {') + ->toContain('fs_extra.removeSync(join(cacheDir, rfaStagedCaches[key]));') + // The child dies with the app and the timeline sees both ends. + ->toContain("app.once('before-quit', stopWithApp);") + ->toContain("globalThis.__rfaLaunchMark?.('php.optimize.started');") + ->toContain("globalThis.__rfaLaunchMark?.('php.optimize.finished');") + // Version bookkeeping happens once the caches are in place. + ->and(strpos($content, 'fs_extra.renameSync('))->toBeLessThan(strpos($content, 'onOptimized();')) + ->and(strpos($content, 'function rfaOptimizeInBackground('))->toBeLessThan(strpos($content, 'function serveApp(')); +}); + // -- Upgrading a file patched by the previous RFA revision -- // The full optimize block the CURRENT patch injects (comment + code), and the -// full block the PREVIOUS revision injected (config:cache warm branch). Used to -// synthesize a faithfully old-patched file from the current patch output. +// full block the PREVIOUS revision injected (synchronous callPhpSync optimize +// behind the same gate). Used to synthesize a faithfully old-patched file from +// the current patch output. function currentServerOptimizeBlock(): string +{ + return <<<'JS' + if (shouldOptimize(store)) { + // [rfa patch] `php artisan optimize` recompiles every Blade view and + // re-caches config/routes/events (~1.7s). Stock NativePHP ran it on + // every launch, blocking the window. The compiled caches persist in + // the build's bootstrap/cache, so it is only needed when the app + // version changes (fresh install / post-update) or a cache file is + // missing, and then it runs in the background while the PHP server + // starts and serves: the framework boots from source until the + // caches land, a few ms per request, instead of holding the window + // for the whole optimize. + // + // On a same-version launch the cache step is skipped ENTIRELY, + // including config:cache for the fresh per-launch API port and IPC + // secret: the app re-reads those two values from the live process + // environment at runtime (RehydrateNativeRuntimeConfigAction, wired + // in bootstrap/app.php via a beforeBootstrapping(RegisterProviders) + // hook that runs before any provider registers), so the persisted + // version-cached config stays valid. + // + // Probe the caches at the directory Laravel actually writes them to + // for this build type. NativePHP only redirects APP_*_CACHE into + // userData/bootstrap/cache for a *secure* build; an unsecure build + // (what `native:build` produces without a bundle, RFA's shipping + // shape) leaves them at /bootstrap/cache. Checking + // bootstrapCache unconditionally would never find them in an unsecure + // build, so the gate would trip every launch and pay the full optimize. + const rfaCacheDir = runningSecureBuild() ? bootstrapCache : join(getAppPath(), 'bootstrap', 'cache'); + const rfaVersionChanged = store.get('optimized_version') !== app.getVersion(); + const rfaNeedsFullOptimize = rfaVersionChanged + || !existsSync(join(rfaCacheDir, 'config.php')) + || !existsSync(join(rfaCacheDir, 'routes-v7.php')) + || !existsSync(join(rfaCacheDir, 'events.php')); + if (rfaNeedsFullOptimize) { + rfaOptimizeInBackground(rfaCacheDir, rfaVersionChanged, phpOptions, phpIniSettings, () => { + store.set('optimized_version', app.getVersion()); + }); + } + } +JS; +} + +function previousRevisionServerOptimizeBlock(): string { return <<<'JS' if (shouldOptimize(store)) { @@ -374,82 +448,50 @@ function currentServerOptimizeBlock(): string JS; } -function previousRevisionServerOptimizeBlock(): string -{ - return <<<'JS' - if (shouldOptimize(store)) { - // [rfa patch] `php artisan optimize` recompiles every Blade view - // (~1s) and previously ran on every launch, blocking the window. - // Compiled views persist in userData and self-heal via on-demand - // compilation, so the full optimize is only needed when the app - // version changes (fresh install / post-update) or the route/event - // caches are missing. On same-version launches we re-cache config - // alone: NativePHP injects a fresh per-launch API port and secret - // that PHP reads through config(), so a reused config cache would - // point the PHP server at a stale port and break the native bridge. - // - // Probe the route/event caches at the directory Laravel actually - // writes them to for this build type. NativePHP only redirects - // APP_ROUTES_CACHE/APP_EVENTS_CACHE into userData/bootstrap/cache - // for a *secure* build; an unsecure build (what `native:build` - // produces without a bundle — RFA's shipping shape) leaves them at - // /bootstrap/cache. Checking bootstrapCache unconditionally - // would never find them in an unsecure build, so the gate would trip - // every launch and pay the full optimize anyway. - const rfaCacheDir = runningSecureBuild() ? bootstrapCache : join(getAppPath(), 'bootstrap', 'cache'); - const rfaVersionChanged = store.get('optimized_version') !== app.getVersion(); - const rfaNeedsFullOptimize = rfaVersionChanged - || !existsSync(join(rfaCacheDir, 'routes-v7.php')) - || !existsSync(join(rfaCacheDir, 'events.php')); - const rfaCommand = rfaNeedsFullOptimize ? 'optimize' : 'config:cache'; - console.log(rfaNeedsFullOptimize ? 'Caching views, routes, and config...' : 'Refreshing config cache...'); - let result = callPhpSync(['artisan', rfaCommand], phpOptions, phpIniSettings); - if (result.status !== 0) { - console.error('Failed to cache framework bootstrap:', result.stderr.toString()); - } - else if (rfaNeedsFullOptimize) { - store.set('optimized_version', app.getVersion()); - } - } -JS; -} - // Build a file exactly as the previous revision left it: the current patch -// output (real opcache edits) with the optimize block reverted to old config:cache. +// output (real opcache edits) with the optimize block reverted to the +// synchronous one and the background helper absent. function oldPatchedServer(): string { - $current = rfaPatchServerOptimize(stockServer()); + $current = (string) rfaPatchServerOptimize(stockServer()); // Guard: if the current patch reshaped, this revert would silently no-op and // the "old" fixture would actually be the new shape. Assert it really swaps. expect($current)->toContain(currentServerOptimizeBlock()); - return str_replace(currentServerOptimizeBlock(), previousRevisionServerOptimizeBlock(), $current); + $reverted = str_replace(currentServerOptimizeBlock(), previousRevisionServerOptimizeBlock(), $current); + $helperStart = strpos($reverted, '// [rfa patch] The framework caches, rebuilt without blocking the launch.'); + $helperEnd = strpos($reverted, 'function serveApp(secret, apiPort, phpIniSettings) {'); + + expect($helperStart)->toBeInt()->and($helperEnd)->toBeGreaterThan($helperStart); + + return substr($reverted, 0, $helperStart).substr($reverted, $helperEnd); } test('a file patched by the previous revision is NOT mistaken for already_patched', function () { - // The old block still carries `rfaNeedsFullOptimize` and the opcache markers, - // so the pre-config.php-probe check would have returned already_patched and - // left the config:cache-every-warm-launch branch in place. + // The old block still carries `rfaNeedsFullOptimize`, the config.php probe, + // and the opcache markers, so a check keyed on those would have returned + // already_patched and left the blocking optimize in place. $old = oldPatchedServer(); expect($old) - ->toContain('rfaCommand') - ->not->toContain("existsSync(join(rfaCacheDir, 'config.php'))"); + ->toContain("callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings)") + ->toContain("existsSync(join(rfaCacheDir, 'config.php'))") + ->not->toContain('rfaOptimizeInBackground'); }); -test('upgrades a previously-patched file to the skip-entirely shape', function () { +test('upgrades a previously-patched file to the background shape', function () { $content = rfaPatchServerOptimize(oldPatchedServer()); expect($content) - // The old config:cache warm-launch branch is gone… - ->not->toContain('rfaCommand') - ->not->toContain("'artisan', 'config:cache'") - // …replaced by the current skip-entirely gate. - ->toContain("existsSync(join(rfaCacheDir, 'config.php'))") + // The synchronous optimize is gone... + ->not->toContain("callPhpSync(['artisan', 'optimize']") + // ...replaced by the background rebuild behind the same gate. + ->toContain('rfaOptimizeInBackground(rfaCacheDir, rfaVersionChanged, phpOptions, phpIniSettings') + ->toContain('function rfaOptimizeInBackground(') ->toContain('if (rfaNeedsFullOptimize) {'); - // The upgrade is byte-identical to a fresh stock → current patch. + // The upgrade is byte-identical to a fresh stock -> current patch. expect($content)->toBe(rfaPatchServerOptimize(stockServer())); }); @@ -1056,6 +1098,11 @@ function indexReadyForLaunchTimeline(): string ->toContain("['startPhpApp', 'php.started', 'after']") ->toContain("['rfaWarmPhp', 'php.warmed', 'after']") ->toContain("this.rfaSplash.once('show', () => rfaLaunchMark('splash.shown'))") + // A background cache rebuild holds the flush for its finish mark, but + // never past the deadline the bootstrap wrapper sets. + ->toContain("rfaLaunchMarked('php.optimize.started') && !rfaLaunchMarked('php.optimize.finished')") + ->toContain('Date.now() < rfaLaunch.deadline') + ->toContain('rfaLaunch.deadline = Date.now() + 60000;') ->toContain('export default new NativePHP();') // The bootstrap sequence the earlier patches verify by exact text is untouched. ->toContain(" const rfaPhpBoot = this.startPhpApp().then(() => this.rfaWarmPhp()).then(() => null, (rfaError) => rfaError);\n yield app.whenReady();\n yield this.rfaResolveAppearance();") @@ -1081,12 +1128,10 @@ function indexReadyForLaunchTimeline(): string expect(rfaPatchLaunchTimelineServer('const reshaped = true;'))->toBeNull(); }); -test('launch timeline server: stamps optimize, migrate, spawn, and listening', function () { +test('launch timeline server: stamps migrate, spawn, and listening', function () { $content = (string) rfaPatchLaunchTimelineServer((string) rfaPatchServerWorkers((string) rfaPatchServerOptimize(stockServer()))); expect($content) - ->toContain("globalThis.__rfaLaunchMark?.('php.optimize.started'); // [rfa launch timeline]") - ->toContain("globalThis.__rfaLaunchMark?.('php.optimize.finished');") ->toContain("globalThis.__rfaLaunchMark?.('php.migrate.started'); // [rfa launch timeline]") ->toContain("globalThis.__rfaLaunchMark?.('php.spawning'); // [rfa launch timeline]") ->toContain("globalThis.__rfaLaunchMark?.('php.port');") From 163f76e78f9e4a4c9dd6b41927613217f5f3b26f Mon Sep 17 00:00:00 2001 From: Franco Gilio Date: Sun, 6 Sep 2026 12:28:37 +0100 Subject: [PATCH 3/4] feat(timeline): wait for the background optimize before flushing The optimize finish mark now lands after the window presents, so the flush holds for it until the 60 s deadline. The report gains a phase for the background rebuild. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RqwYa86be82YSKkJYb9VLz --- .claude/skills/rfa-debug/SKILL.md | 2 +- app/Services/LaunchTimelineService.php | 1 + tests/Unit/Services/LaunchTimelineServiceTest.php | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/skills/rfa-debug/SKILL.md b/.claude/skills/rfa-debug/SKILL.md index 055c098a..5be35251 100644 --- a/.claude/skills/rfa-debug/SKILL.md +++ b/.claude/skills/rfa-debug/SKILL.md @@ -42,7 +42,7 @@ When copying the SQLite file while the app is running, copy `database.sqlite`, ` ## Cold-launch timeline -Every launch writes one line to `/logs/rfa-launch.jsonl` from the Electron main process (marks in ms since process creation: PHP spawn, listening, warm, splash, booted handshake, window open, load, renderer-ready, presented). The PHP side stamps its breadcrumbs in `rfa-diagnostics.jsonl` with the request start (a `request` object holding `started_at_ms` and `elapsed_ms`), and the renderer posts a `launch` browser sample with navigation timing and the settle sub-marks. The file rotates at 1MB. +Every launch writes one line to `/logs/rfa-launch.jsonl` from the Electron main process (marks in ms since process creation: PHP spawn, listening, warm, splash, booted handshake, window open, load, renderer-ready, presented, and after a version change the background cache rebuild as `php.optimize.started`/`finished`, which the flush waits for). The PHP side stamps its breadcrumbs in `rfa-diagnostics.jsonl` with the request start (a `request` object holding `started_at_ms` and `elapsed_ms`), and the renderer posts a `launch` browser sample with navigation timing and the settle sub-marks. The file rotates at 1MB. Read the three as one timeline (defaults to the installed app's log directory, medians over the last launches): diff --git a/app/Services/LaunchTimelineService.php b/app/Services/LaunchTimelineService.php index 47cbea24..bb36b1ef 100644 --- a/app/Services/LaunchTimelineService.php +++ b/app/Services/LaunchTimelineService.php @@ -40,6 +40,7 @@ final class LaunchTimelineService public const PHASES = [ 'electron: process -> bootstrap' => [null, 'bootstrap'], 'electron: bootstrap -> app ready' => ['bootstrap', 'app.ready'], + 'php: optimize started -> finished (background)' => ['php.optimize.started', 'php.optimize.finished'], 'php: spawn -> listening' => ['php.spawning', 'php.listening'], 'php: listening -> warm request' => ['php.listening', 'php.warm.request'], 'php: warm request -> warmed' => ['php.warm.request', 'php.warmed'], diff --git a/tests/Unit/Services/LaunchTimelineServiceTest.php b/tests/Unit/Services/LaunchTimelineServiceTest.php index cafa7f42..dbfd8e10 100644 --- a/tests/Unit/Services/LaunchTimelineServiceTest.php +++ b/tests/Unit/Services/LaunchTimelineServiceTest.php @@ -121,14 +121,17 @@ function writeLaunchFixture(string $dir, int $t0, array $marks = ['bootstrap' => $phases = app(LaunchTimelineService::class)->phases([ 'bootstrap' => 120, 'app.ready' => 500, + 'php.optimize.started' => 190, 'php.spawning' => 200, 'php.listening' => 400, 'window.presented' => 1710, + 'php.optimize.finished' => 1900, ]); expect($phases)->toBe([ 'electron: process -> bootstrap' => 120, 'electron: bootstrap -> app ready' => 380, + 'php: optimize started -> finished (background)' => 1710, 'php: spawn -> listening' => 200, 'total: process -> presented' => 1710, ]); From a48bd8508be96522c0f2b7df08b8faa7fa99312a Mon Sep 17 00:00:00 2001 From: Franco Gilio Date: Sun, 6 Sep 2026 14:04:09 +0100 Subject: [PATCH 4/4] fix(native): drop the compiled views on a version change Blade and Livewire keep a compiled file while it is newer than its source, and a build's sources predate whatever the previous version compiled. rfa:optimize never clears the live directory, so a stale view survived an update until something recompiled it. The main process now empties storage/framework/views before the server spawns whenever the app version changed. The in-place upgrade from the synchronous optimize block goes with it: the runner now patches from a stored stock copy. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RqwYa86be82YSKkJYb9VLz --- scripts/patch-nativephp.php | 78 ++--------- tests/Unit/Scripts/PatchNativePhpTest.php | 161 ++-------------------- 2 files changed, 25 insertions(+), 214 deletions(-) diff --git a/scripts/patch-nativephp.php b/scripts/patch-nativephp.php index e0ce25a7..5e6acadc 100644 --- a/scripts/patch-nativephp.php +++ b/scripts/patch-nativephp.php @@ -556,57 +556,6 @@ function rfaPatchServerOptimize(string $content): ?string } JS; - // The optimize block as the PREVIOUS RFA revision left it: the same gate, - // but the optimize ran synchronously through callPhpSync and held the - // launch for the whole cache rebuild. The stock find above no longer - // matches such a file, so without this an already-patched vendor copy - // would keep blocking. Replacing the whole old block upgrades it to the - // current background shape, byte-identical to a fresh patch. - $oldOptimizeFind = <<<'JS' - if (shouldOptimize(store)) { - // [rfa patch] `php artisan optimize` recompiles every Blade view and - // re-caches config/routes/events (~1s) and previously ran on every - // launch, blocking the window. The compiled caches persist in the - // build's bootstrap/cache, so the full optimize is only needed when - // the app version changes (fresh install / post-update) or a cache - // file is missing. - // - // On a same-version launch we skip the cache step ENTIRELY — including - // the config:cache the earlier RFA patch ran for the fresh per-launch - // API port and IPC secret. Those two values are the only per-launch - // config that varies, and the app now re-reads them from the live - // process environment at runtime (RehydrateNativeRuntimeConfigAction, - // wired in bootstrap/app.php via a beforeBootstrapping(RegisterProviders) - // hook that runs before any provider registers), so the persisted - // version-cached config stays valid and we avoid a full framework boot - // on every warm launch. - // - // Probe the caches at the directory Laravel actually writes them to - // for this build type. NativePHP only redirects APP_*_CACHE into - // userData/bootstrap/cache for a *secure* build; an unsecure build - // (what `native:build` produces without a bundle — RFA's shipping - // shape) leaves them at /bootstrap/cache. Checking - // bootstrapCache unconditionally would never find them in an unsecure - // build, so the gate would trip every launch and pay the full optimize. - const rfaCacheDir = runningSecureBuild() ? bootstrapCache : join(getAppPath(), 'bootstrap', 'cache'); - const rfaVersionChanged = store.get('optimized_version') !== app.getVersion(); - const rfaNeedsFullOptimize = rfaVersionChanged - || !existsSync(join(rfaCacheDir, 'config.php')) - || !existsSync(join(rfaCacheDir, 'routes-v7.php')) - || !existsSync(join(rfaCacheDir, 'events.php')); - if (rfaNeedsFullOptimize) { - console.log('Caching views, routes, and config...'); - let result = callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings); - if (result.status !== 0) { - console.error('Failed to cache framework bootstrap:', result.stderr.toString()); - } - else { - store.set('optimized_version', app.getVersion()); - } - } - } -JS; - // The background runner lives next to serveApp() so the optimize block // stays a gate and a call. It relies on php.js's own imports (fs_extra, // mkdirpSync, join, app, callPhp) so no import line changes. @@ -625,7 +574,11 @@ function rfaPatchServerOptimize(string $content): ?string // (view:cache does, which is why plain `optimize` is not used here). // // After a version change the caches left by the previous version are removed -// before the server spawns, so the new code never boots against them. +// before the server spawns, so the new code never boots against them. That +// includes the compiled views: Blade and Livewire both keep a compiled file +// while it is newer than its source, and a build's sources predate whatever +// the previous version compiled, so a stale view would otherwise be served +// for as long as it is not recompiled. const rfaStagedCaches = { APP_CONFIG_CACHE: 'config.php', APP_ROUTES_CACHE: 'routes-v7.php', @@ -643,6 +596,9 @@ function rfaOptimizeInBackground(cacheDir, versionChanged, phpOptions, phpIniSet fs_extra.removeSync(join(cacheDir, rfaStagedCaches[key])); } }); + if (versionChanged) { + fs_extra.emptyDirSync(join(storagePath, 'framework', 'views')); + } } catch (error) { console.error('Failed to prepare the framework cache staging directory:', error); @@ -715,13 +671,6 @@ function serveApp(secret, apiPort, phpIniSettings) { $patched = str_replace($optimizeFind, $optimizeReplace, $patched); } - // Upgrade a file left patched by the previous RFA revision in place. Only one - // of these two finds can match (stock OR old-patched), so this never double- - // applies; on the current shape neither matches. - if (str_contains($patched, $oldOptimizeFind)) { - $patched = str_replace($oldOptimizeFind, $optimizeReplace, $patched); - } - if (str_contains($patched, $helperFind) && ! str_contains($patched, $helperMarker)) { $patched = str_replace($helperFind, $helperReplace, $patched); } @@ -736,13 +685,10 @@ function serveApp(secret, apiPort, phpIniSettings) { } // Only report success when every edit is present in the result. The - // background call is the marker UNIQUE to the current shape: requiring it - // (not just `rfaNeedsFullOptimize` or the config.php probe, which the - // previous revision also had) means a file still carrying the synchronous - // callPhpSync optimize is treated as not-yet-patched rather than - // mis-reported as already_patched. The two opcache markers are each UNIQUE - // to their edit: the mkdir comment proves the cache directory is created, - // and the pre-flight banner (x2) proves both retrieve* helpers reuse it. + // background call and the helper are the markers of the optimize edit. The + // two opcache markers are each UNIQUE to their edit: the mkdir comment + // proves the cache directory is created, and the pre-flight banner (x2) + // proves both retrieve* helpers reuse it. // (`'framework', 'opcache'` alone would be ambiguous: the pre-flight // `opcache.file_cache=...` path contains that same substring, so a file // with only the pre-flight edit could mis-report as fully patched while diff --git a/tests/Unit/Scripts/PatchNativePhpTest.php b/tests/Unit/Scripts/PatchNativePhpTest.php index 0d4c845f..f3418943 100644 --- a/tests/Unit/Scripts/PatchNativePhpTest.php +++ b/tests/Unit/Scripts/PatchNativePhpTest.php @@ -350,155 +350,20 @@ ->and(strpos($content, 'function rfaOptimizeInBackground('))->toBeLessThan(strpos($content, 'function serveApp(')); }); -// -- Upgrading a file patched by the previous RFA revision -- - -// The full optimize block the CURRENT patch injects (comment + code), and the -// full block the PREVIOUS revision injected (synchronous callPhpSync optimize -// behind the same gate). Used to synthesize a faithfully old-patched file from -// the current patch output. -function currentServerOptimizeBlock(): string -{ - return <<<'JS' - if (shouldOptimize(store)) { - // [rfa patch] `php artisan optimize` recompiles every Blade view and - // re-caches config/routes/events (~1.7s). Stock NativePHP ran it on - // every launch, blocking the window. The compiled caches persist in - // the build's bootstrap/cache, so it is only needed when the app - // version changes (fresh install / post-update) or a cache file is - // missing, and then it runs in the background while the PHP server - // starts and serves: the framework boots from source until the - // caches land, a few ms per request, instead of holding the window - // for the whole optimize. - // - // On a same-version launch the cache step is skipped ENTIRELY, - // including config:cache for the fresh per-launch API port and IPC - // secret: the app re-reads those two values from the live process - // environment at runtime (RehydrateNativeRuntimeConfigAction, wired - // in bootstrap/app.php via a beforeBootstrapping(RegisterProviders) - // hook that runs before any provider registers), so the persisted - // version-cached config stays valid. - // - // Probe the caches at the directory Laravel actually writes them to - // for this build type. NativePHP only redirects APP_*_CACHE into - // userData/bootstrap/cache for a *secure* build; an unsecure build - // (what `native:build` produces without a bundle, RFA's shipping - // shape) leaves them at /bootstrap/cache. Checking - // bootstrapCache unconditionally would never find them in an unsecure - // build, so the gate would trip every launch and pay the full optimize. - const rfaCacheDir = runningSecureBuild() ? bootstrapCache : join(getAppPath(), 'bootstrap', 'cache'); - const rfaVersionChanged = store.get('optimized_version') !== app.getVersion(); - const rfaNeedsFullOptimize = rfaVersionChanged - || !existsSync(join(rfaCacheDir, 'config.php')) - || !existsSync(join(rfaCacheDir, 'routes-v7.php')) - || !existsSync(join(rfaCacheDir, 'events.php')); - if (rfaNeedsFullOptimize) { - rfaOptimizeInBackground(rfaCacheDir, rfaVersionChanged, phpOptions, phpIniSettings, () => { - store.set('optimized_version', app.getVersion()); - }); - } - } -JS; -} - -function previousRevisionServerOptimizeBlock(): string -{ - return <<<'JS' - if (shouldOptimize(store)) { - // [rfa patch] `php artisan optimize` recompiles every Blade view and - // re-caches config/routes/events (~1s) and previously ran on every - // launch, blocking the window. The compiled caches persist in the - // build's bootstrap/cache, so the full optimize is only needed when - // the app version changes (fresh install / post-update) or a cache - // file is missing. - // - // On a same-version launch we skip the cache step ENTIRELY — including - // the config:cache the earlier RFA patch ran for the fresh per-launch - // API port and IPC secret. Those two values are the only per-launch - // config that varies, and the app now re-reads them from the live - // process environment at runtime (RehydrateNativeRuntimeConfigAction, - // wired in bootstrap/app.php via a beforeBootstrapping(RegisterProviders) - // hook that runs before any provider registers), so the persisted - // version-cached config stays valid and we avoid a full framework boot - // on every warm launch. - // - // Probe the caches at the directory Laravel actually writes them to - // for this build type. NativePHP only redirects APP_*_CACHE into - // userData/bootstrap/cache for a *secure* build; an unsecure build - // (what `native:build` produces without a bundle — RFA's shipping - // shape) leaves them at /bootstrap/cache. Checking - // bootstrapCache unconditionally would never find them in an unsecure - // build, so the gate would trip every launch and pay the full optimize. - const rfaCacheDir = runningSecureBuild() ? bootstrapCache : join(getAppPath(), 'bootstrap', 'cache'); - const rfaVersionChanged = store.get('optimized_version') !== app.getVersion(); - const rfaNeedsFullOptimize = rfaVersionChanged - || !existsSync(join(rfaCacheDir, 'config.php')) - || !existsSync(join(rfaCacheDir, 'routes-v7.php')) - || !existsSync(join(rfaCacheDir, 'events.php')); - if (rfaNeedsFullOptimize) { - console.log('Caching views, routes, and config...'); - let result = callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings); - if (result.status !== 0) { - console.error('Failed to cache framework bootstrap:', result.stderr.toString()); - } - else { - store.set('optimized_version', app.getVersion()); - } - } - } -JS; -} - -// Build a file exactly as the previous revision left it: the current patch -// output (real opcache edits) with the optimize block reverted to the -// synchronous one and the background helper absent. -function oldPatchedServer(): string -{ - $current = (string) rfaPatchServerOptimize(stockServer()); - - // Guard: if the current patch reshaped, this revert would silently no-op and - // the "old" fixture would actually be the new shape. Assert it really swaps. - expect($current)->toContain(currentServerOptimizeBlock()); - - $reverted = str_replace(currentServerOptimizeBlock(), previousRevisionServerOptimizeBlock(), $current); - $helperStart = strpos($reverted, '// [rfa patch] The framework caches, rebuilt without blocking the launch.'); - $helperEnd = strpos($reverted, 'function serveApp(secret, apiPort, phpIniSettings) {'); - - expect($helperStart)->toBeInt()->and($helperEnd)->toBeGreaterThan($helperStart); - - return substr($reverted, 0, $helperStart).substr($reverted, $helperEnd); -} - -test('a file patched by the previous revision is NOT mistaken for already_patched', function () { - // The old block still carries `rfaNeedsFullOptimize`, the config.php probe, - // and the opcache markers, so a check keyed on those would have returned - // already_patched and left the blocking optimize in place. - $old = oldPatchedServer(); - - expect($old) - ->toContain("callPhpSync(['artisan', 'optimize'], phpOptions, phpIniSettings)") - ->toContain("existsSync(join(rfaCacheDir, 'config.php'))") - ->not->toContain('rfaOptimizeInBackground'); -}); - -test('upgrades a previously-patched file to the background shape', function () { - $content = rfaPatchServerOptimize(oldPatchedServer()); - - expect($content) - // The synchronous optimize is gone... - ->not->toContain("callPhpSync(['artisan', 'optimize']") - // ...replaced by the background rebuild behind the same gate. - ->toContain('rfaOptimizeInBackground(rfaCacheDir, rfaVersionChanged, phpOptions, phpIniSettings') - ->toContain('function rfaOptimizeInBackground(') - ->toContain('if (rfaNeedsFullOptimize) {'); - - // The upgrade is byte-identical to a fresh stock -> current patch. - expect($content)->toBe(rfaPatchServerOptimize(stockServer())); -}); - -test('upgrading a previously-patched file is idempotent', function () { - $upgraded = rfaPatchServerOptimize(oldPatchedServer()); +test('a version change empties the compiled views before the server spawns', function () { + // Blade and Livewire keep a compiled file while it is newer than its + // source. A build's sources predate anything the previous version + // compiled, so its compiled views would be served as fresh until the + // directory is cleared. rfa:optimize never clears it, so the main + // process does, and only ahead of the spawn. + $content = (string) rfaPatchServerOptimize(stockServer()); + $helper = substr($content, strpos($content, 'function rfaOptimizeInBackground('), strpos($content, 'function serveApp(') - strpos($content, 'function rfaOptimizeInBackground(')); - expect(rfaPatchServerOptimize($upgraded))->toBe($upgraded); + expect($helper) + ->toContain("fs_extra.emptyDirSync(join(storagePath, 'framework', 'views'));") + ->and(strpos($helper, 'if (versionChanged) {'))->toBeLessThan(strpos($helper, "emptyDirSync(join(storagePath, 'framework', 'views')")) + ->and(strpos($helper, "emptyDirSync(join(storagePath, 'framework', 'views')"))->toBeLessThan(strpos($helper, "callPhp(['artisan', 'rfa:optimize']")) + ->and(strpos($content, 'rfaOptimizeInBackground(rfaCacheDir, rfaVersionChanged'))->toBeLessThan(strpos($content, "callPhp(['-S'")); }); // -- Idempotency --