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
53 changes: 32 additions & 21 deletions .buildkite/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@
env:
IMAGE_ID: $IMAGE_ID

e2e_config: &e2e_config
command: bash .buildkite/commands/run-e2e-tests.sh "{{matrix.platform}}" "{{matrix.arch}}"
artifact_paths:
- test-results/**/*.zip
- test-results/**/*.png
- test-results/**/*error-context.md
plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN]
agents:
queue: "{{matrix.platform}}"
env:
# See https://playwright.dev/docs/ci#debugging-browser-launches
DEBUG: "pw:browser"
matrix:
setup: { platform: [], arch: [] }
adjustments:
- with: { platform: mac, arch: arm64 }
- with: { platform: windows, arch: x64 }
notify:
- github_commit_status:
context: E2E Tests

steps:
- label: Lint
agents:
Expand Down Expand Up @@ -51,30 +72,20 @@ steps:
# E2E tests run on supported platform/architecture combinations.
# - mac-arm64: Native on Apple Silicon agents
# - windows-x64: Native on x64 agents
- label: E2E Tests on {{matrix.platform}}-{{matrix.arch}}
- <<: *e2e_config
label: E2E Tests on {{matrix.platform}}-{{matrix.arch}}
key: e2e_tests
command: bash .buildkite/commands/run-e2e-tests.sh "{{matrix.platform}}" "{{matrix.arch}}"
artifact_paths:
- test-results/**/*.zip
- test-results/**/*.png
- test-results/**/*error-context.md
plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN]
agents:
queue: "{{matrix.platform}}"
env:
# See https://playwright.dev/docs/ci#debugging-browser-launches
DEBUG: "pw:browser"
matrix:
setup: { platform: [], arch: [] }
adjustments:
- with: { platform: mac, arch: arm64 }
- with: { platform: windows, arch: x64 }
# Skip E2E tests on draft PRs to save CI resources
# Tests will run automatically when PR is marked ready for review
if: build.branch == 'trunk' || build.tag =~ /^v[0-9]+/ || !build.pull_request.draft
notify:
- github_commit_status:
context: E2E Tests
if: (build.branch == 'trunk' || build.tag =~ /^v[0-9]+/ || !build.pull_request.draft) && !(build.pull_request.labels includes 'native-php')

- <<: *e2e_config
label: E2E Tests on {{matrix.platform}}-{{matrix.arch}} with native PHP
key: e2e_tests_native_php
env:
DEBUG: "pw:browser"
STUDIO_RUNTIME: native-php
if: (build.branch == 'trunk' || build.tag =~ /^v[0-9]+/ || !build.pull_request.draft) && build.pull_request.labels includes 'native-php'

- label: ":chart_with_upwards_trend: Performance Metrics"
key: metrics
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ typings/
out/

# The latest WordPress files downloaded from the web
wp-files/
wp-files/*
!wp-files/blueprints/
wp-files/blueprints/*
!wp-files/blueprints/blueprints.phar

# Release Tooling
vendor/*
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/commands/wp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SiteData } from 'cli/lib/cli-config/core';
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getPhpBinaryPath, getWpCliPharPath } from 'cli/lib/dependency-management/paths';
import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary';
import { runWpCliCommand, runGlobalWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command';
import { validatePhpVersion } from 'cli/lib/utils';
import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager';
Expand Down Expand Up @@ -44,6 +45,7 @@ enum Mode {

async function runNativePhpWpCliCommand( site: SiteData, args: string[] ): Promise< void > {
const phpVersion = validateNativePhpVersion( site.phpVersion );
await ensurePhpBinaryAvailable( phpVersion );
await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating );
const child = spawn(
getPhpBinaryPath( phpVersion ),
Expand Down
25 changes: 22 additions & 3 deletions apps/cli/lib/dependency-management/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import ignore from 'ignore';

export { downloadFile } from '@studio/common/lib/download-file';
Expand All @@ -17,7 +18,17 @@ async function collectDirectoryMetadata(
basePath = directoryPath
): Promise< Map< string, FileMetadata > > {
const files = new Map< string, FileMetadata >();
const entries = await fs.promises.readdir( directoryPath, { withFileTypes: true } );
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir( directoryPath, { withFileTypes: true } );
} catch ( error ) {
// Directory disappeared between the parent's readdir and our entry into it.
// Treat as empty so the caller can still compare what remains.
if ( isErrnoException( error ) && error.code === 'ENOENT' ) {
return files;
}
throw error;
}

for ( const entry of entries ) {
const fullPath = path.join( directoryPath, entry.name );
Expand All @@ -39,8 +50,16 @@ async function collectDirectoryMetadata(
continue;
}

const stats = await fs.promises.lstat( fullPath );
files.set( relativePath, { size: stats.size, mtimeMs: Math.floor( stats.mtimeMs ) } );
try {
const stats = await fs.promises.lstat( fullPath );
files.set( relativePath, { size: stats.size, mtimeMs: Math.floor( stats.mtimeMs ) } );
} catch ( error ) {
// File vanished between readdir and lstat (e.g. concurrent cleanup). Skip it.
if ( isErrnoException( error ) && error.code === 'ENOENT' ) {
continue;
}
throw error;
}
}

return files;
Expand Down
38 changes: 31 additions & 7 deletions apps/cli/lib/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { DaemonResponse } from 'cli/lib/types/process-manager-ipc';

const DEFAULT_CONNECT_TIMEOUT_MS = 500;
const DEFAULT_RECONNECT_DELAY_MS = 500;
// How long to wait for a socket's pending writes to flush during graceful close before
// giving up and forcefully destroying it.
const GRACEFUL_SOCKET_END_TIMEOUT_MS = 500;

function isWindowsNamedPipe( endpoint: string ): boolean {
return endpoint.startsWith( '\\\\.\\pipe\\' );
Expand Down Expand Up @@ -421,20 +424,41 @@ export class SocketServer extends SocketServerEventEmitter {
} );
}

close(): Promise< void > {
return new Promise< void >( ( resolve ) => {
for ( const socket of this.sockets ) {
socket.destroy();
}
async close(): Promise< void > {
// End each socket gracefully so any pending writes (e.g. an in-flight response)
// have a chance to flush. Forcefully destroy as a fallback if the peer is slow.
await Promise.all(
Array.from( this.sockets ).map( ( socket ) => this.endSocketGracefully( socket ) )
);

if ( ! this.server.listening ) {
return;
}

await new Promise< void >( ( resolve ) => {
this.server.close( () => {
resolve();
} );
} );
}

if ( ! this.server.listening ) {
private endSocketGracefully( socket: net.Socket ): Promise< void > {
return new Promise< void >( ( resolve ) => {
if ( socket.destroyed ) {
resolve();
return;
}

this.server.close( () => {
const fallback = setTimeout( () => {
socket.destroy();
}, GRACEFUL_SOCKET_END_TIMEOUT_MS );

socket.once( 'close', () => {
clearTimeout( fallback );
resolve();
} );

socket.end();
} );
}
}
7 changes: 7 additions & 0 deletions apps/cli/lib/wordpress-server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
PLAYGROUND_CLI_MAX_TIMEOUT,
} from '@studio/common/constants';
import { SiteCommandLoggerAction } from '@studio/common/logger-actions';
import { __ } from '@wordpress/i18n';
import { z } from 'zod';
import { SiteData, SiteRuntime } from 'cli/lib/cli-config/core';
import {
Expand Down Expand Up @@ -197,6 +198,11 @@ export async function startWordPressServer(

await ensurePhpBinaryAvailableIfNeeded( site, logger );

const startMessage = options?.blueprint
? __( 'Starting WordPress server and applying Blueprint…' )
: __( 'Starting WordPress server…' );
logger.reportStart( SiteCommandLoggerAction.START_SITE, startMessage );

const wordPressServerChildPath = getChildScriptPath( site.runtime );
const processName = getProcessName( site.id );
const serverConfig = buildServerConfig( site, options );
Expand Down Expand Up @@ -514,6 +520,7 @@ export async function runBlueprint(
options: RunBlueprintOptions
): Promise< void > {
await ensurePhpBinaryAvailableIfNeeded( site, logger );
logger.reportStart( SiteCommandLoggerAction.APPLY_BLUEPRINT, __( 'Applying Blueprint…' ) );

const wordPressServerChildPath = getChildScriptPath( site.runtime );
const processName = getProcessName( site.id );
Expand Down
58 changes: 57 additions & 1 deletion apps/cli/php-server-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,61 @@ type SpawnPhpProcessOptions = {
mode?: 'pipe' | 'capture-stdout';
};

// Process-scoped opcache dir, created lazily and removed when the process exits
let opcacheRootDir: string | null = null;

function getOpcacheRootDir(): string {
if ( opcacheRootDir ) {
return opcacheRootDir;
}

// Resolve to the long-form path on Windows. `os.tmpdir()` can return an 8.3
// short name (e.g. C:\Users\BUILDK~1\AppData\…) when the user has a long
// username, and PHP's INI scanner treats `~` as a special token, breaking
// `-d opcache.file_cache=<path>` parsing.
const tmpRoot =
process.platform === 'win32' ? fs.realpathSync.native( os.tmpdir() ) : os.tmpdir();
opcacheRootDir = fs.mkdtempSync( path.join( tmpRoot, 'studio-opcache-' ) );
const dirToClean = opcacheRootDir;
process.once( 'exit', () => {
try {
fs.rmSync( dirToClean, { recursive: true, force: true } );
} catch {
// Best effort. The OS will reap tmp eventually.
}
} );
return opcacheRootDir;
}

function getDefaultPhpArgs( phpVersion: NativePhpSupportedVersion ): string[] {
if ( process.platform !== 'win32' ) {
return [];
}

// Partition the file_cache by PHP version: opcache's on-disk script blob
// format isn't stable across minor versions, and reusing a cache populated
// by a different PHP can crash the server at startup on Windows.
const cacheId = `php${ phpVersion }`;
const cacheDirectory = path.join( getOpcacheRootDir(), cacheId );
fs.mkdirSync( cacheDirectory, { recursive: true } );

return [
'-d',
`opcache.file_cache="${ cacheDirectory }"`,
'-d',
'opcache.file_cache_fallback=1',
'-d',
`opcache.cache_id="studio-${ cacheId }"`,
];
}

function spawnPhpProcess(
args: string[],
{ phpVersion, cwd, signal, mode = 'pipe' }: SpawnPhpProcessOptions
): ChildProcess {
const phpScriptProcess = spawn( getPhpBinaryPath( phpVersion ), args, {
const defaultArgs = getDefaultPhpArgs( phpVersion );
const phpArgs = [ ...defaultArgs, ...args ];
const phpScriptProcess = spawn( getPhpBinaryPath( phpVersion ), phpArgs, {
cwd,
stdio: [ 'ignore', 'pipe', 'pipe' ],
signal,
Expand Down Expand Up @@ -627,6 +677,8 @@ async function ipcMessageHandler( packet: unknown ) {
};
process.send!( response );

// If the `stopServer` function ran successfully, the last open handle should be the IPC channel.
// Disconnect so that the process can exit cleanly.
if ( validMessage.topic === 'stop-server' && result === StopServerResult.OK ) {
process.disconnect();
}
Expand Down Expand Up @@ -663,7 +715,11 @@ function shutdownOnSignal( signal: NodeJS.Signals ): void {
// If this node process is going down (normal exit or IPC disconnect), make sure PHP goes with it.
process.on( 'exit', killPhpProcess );
process.on( 'disconnect', () => {
logToConsole( 'IPC channel disconnected, shutting down' );
killPhpProcess();
// Without an explicit exit, the wrapper would linger until the event loop drains,
// which delays the daemon's stop sequence and risks the force-kill timer firing.
process.exit( 0 );
} );

// Without explicit signal handlers, the process is terminated abruptly and the 'exit' event
Expand Down
Loading
Loading