Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a57383e
Don't start a site while a stop for it is still in flight
bcotrim Jul 31, 2026
3e609c6
Merge remote-tracking branch 'origin/trunk' into guard-site-start-whi…
bcotrim Aug 7, 2026
31b5e90
Serialize concurrent site operations behind a CLI-owned lease
bcotrim Aug 7, 2026
4712457
Keep site operation progress visible across navigation
bcotrim Aug 7, 2026
5d8d689
Share one source for site management action state
bcotrim Aug 7, 2026
7d47734
Merge remote-tracking branch 'origin/trunk' into guard-site-start-whi…
bcotrim Aug 7, 2026
8ee29d3
Address review: surface why an action was refused, bound stale leases
bcotrim Aug 7, 2026
d1ca661
Reclaim site operation leases written before the current boot
bcotrim Aug 7, 2026
f941973
Key site operation leases on the owning process alone
bcotrim Aug 7, 2026
8a1357a
Hold one site operation at a time
bcotrim Aug 7, 2026
faa7bfa
trigger ci
bcotrim Aug 7, 2026
0280c80
Replace nested ternaries in site status derivation with named helpers
bcotrim Aug 7, 2026
0a26f98
Merge branch 'trunk' into guard-site-start-while-stopping
bcotrim Aug 10, 2026
27f327f
Merge branch 'trunk' into guard-site-start-while-stopping
bcotrim Aug 10, 2026
f0bcaab
DIAGNOSTIC (do not merge): drop lease events to test the metrics failure
bcotrim Aug 10, 2026
6d4bb0c
Merge remote-tracking branch 'origin/guard-site-start-while-stopping'…
bcotrim Aug 10, 2026
0eea663
Announce lease changes without asserting a running state
bcotrim Aug 10, 2026
57cd532
Merge remote-tracking branch 'origin/trunk' into guard-site-start-whi…
bcotrim Aug 11, 2026
eca8729
Standardize site operation terminology and clarify comments
bcotrim Aug 11, 2026
3419d61
Merge remote-tracking branch 'origin/trunk' into guard-site-start-whi…
bcotrim Aug 11, 2026
9bf8423
Merge remote-tracking branch 'origin/trunk' into guard-site-start-whi…
bcotrim Aug 11, 2026
16fb848
Merge remote-tracking branch 'origin/trunk' into guard-site-start-whi…
bcotrim Aug 12, 2026
f3daa1b
Narrow the site operation guard and store one operation per site
bcotrim Aug 12, 2026
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
4 changes: 4 additions & 0 deletions apps/cli/commands/_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
SITE_EVENTS_SOCKET_PATH,
getDaemonBus,
} from 'cli/lib/daemon-client';
import { getLiveSiteOperation } from 'cli/lib/site-operations';
import { isSiteRunning } from 'cli/lib/site-utils';
import { SocketServer } from 'cli/lib/socket';
import { SITE_PROCESS_PREFIX } from 'cli/lib/wordpress-server-manager';
Expand All @@ -39,6 +40,8 @@ const logger = new Logger< LoggerAction >();
function toSiteDetails( site: SiteData ) {
return siteDetailsSchema.parse( {
...site,
// Overrides rather than augments `...site` — see the note in `site list`.
operation: getLiveSiteOperation( site ),
url: getSiteUrl( site ),
} );
}
Expand Down Expand Up @@ -138,6 +141,7 @@ export async function runCommand(): Promise< void > {
case SITE_EVENTS.CREATED:
case SITE_EVENTS.UPDATED:
case SITE_EVENTS.DELETED:
case SITE_EVENTS.OPERATIONS_CHANGED:
void emitSiteEvent( parsed.event, parsed.data.siteId );
break;
}
Expand Down
35 changes: 35 additions & 0 deletions apps/cli/commands/config/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/dae
import { updateDomainInHosts } from 'cli/lib/hosts-file';
import { validateSupportedPhpVersion } from 'cli/lib/php-versions';
import { runWpCliCommand } from 'cli/lib/run-wp-cli-command';
import { withSiteOperation } from 'cli/lib/site-operations';
import { setupCustomDomain } from 'cli/lib/site-utils';
import { ValidationError } from 'cli/lib/validation-error';
import {
Expand Down Expand Up @@ -73,6 +74,14 @@ export interface SetCommandOptions {
}

export async function runCommand( sitePath: string, options: SetCommandOptions ): Promise< void > {
const validated = validateSetOptions( options );
return withSiteOperation( sitePath, 'settings', () => setSiteConfig( sitePath, validated ) );
}

// Runs before the operation is recorded, so an invalid edit fails without
// touching the config file or briefly blocking the site. Returns the
// options with `adminEmail` normalized (blank means "leave it alone").
function validateSetOptions( options: SetCommandOptions ): SetCommandOptions {
const {
name,
domain,
Expand Down Expand Up @@ -126,6 +135,12 @@ export async function runCommand( sitePath: string, options: SetCommandOptions )
throw new LoggerError( __( 'Admin password cannot be empty.' ) );
}

// Static check, so it belongs out here with the rest. The runtime-specific
// PHP check further down needs the site record and has to stay inside.
if ( options.php !== undefined ) {
validateSupportedPhpVersion( options.php );
}

if ( adminEmail !== undefined ) {
if ( ! adminEmail.trim() ) {
adminEmail = undefined;
Expand All @@ -137,6 +152,26 @@ export async function runCommand( sitePath: string, options: SetCommandOptions )
}
}

return { ...options, adminEmail };
}

async function setSiteConfig( sitePath: string, options: SetCommandOptions ): Promise< void > {
const {
name,
domain,
https,
php,
wp,
runtime,
fileAccess,
xdebug,
adminUsername,
adminPassword,
adminEmail,
debugLog,
debugDisplay,
} = options;

try {
logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) );
let site = await getSiteByFolder( sitePath );
Expand Down
17 changes: 17 additions & 0 deletions apps/cli/commands/config/tests/set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ vi.mock( 'cli/lib/cli-config/sites', async () => {
vi.mock( 'cli/lib/certificate-manager' );
vi.mock( 'cli/lib/hosts-file' );
vi.mock( 'cli/lib/daemon-client' );
// Run the command body directly: this suite covers the command, not the
// operation guard (lib/tests/site-operations.test.ts does that). Spreading the real module keeps
// any other export real rather than silently stubbing it.
vi.mock( 'cli/lib/site-operations', async ( importOriginal ) => ( {
...( await importOriginal< typeof import('cli/lib/site-operations') >() ),
withSiteOperation: vi.fn( ( _folder: string, _kind: string, fn: () => unknown ) => fn() ),
} ) );
vi.mock( 'cli/lib/run-wp-cli-command' );
vi.mock( 'cli/lib/site-utils' );
vi.mock( 'cli/lib/wordpress-server-manager' );
Expand Down Expand Up @@ -160,6 +167,16 @@ describe( 'CLI: studio config set', () => {
);
} );

// Validation runs before the operation is recorded, so a rejected
// edit neither writes the config nor briefly blocks the site.
it( 'should reject an invalid edit without claiming the site', async () => {
const { withSiteOperation } = await import( 'cli/lib/site-operations' );

await expect( runCommand( testSitePath, { php: '8.1' } ) ).rejects.toThrow();

expect( withSiteOperation ).not.toHaveBeenCalled();
} );

it( 'should throw when PHP version is not supported', async () => {
await expect( runCommand( testSitePath, { php: '8.1' } ) ).rejects.toThrow(
'PHP 8.1 is not supported. Supported versions: 8.5, 8.4, 8.3, 8.2.'
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/commands/site/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client';
import { removeDomainFromHosts } from 'cli/lib/hosts-file';
import { withSiteOperation } from 'cli/lib/site-operations';
import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils';
import { getSnapshotsFromConfig, deleteSnapshotFromConfig } from 'cli/lib/snapshots';
import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks';
Expand Down Expand Up @@ -70,6 +71,10 @@ export async function runCommand(
siteFolder: string,
deleteFiles: boolean = true
): Promise< void > {
return withSiteOperation( siteFolder, 'delete', () => deleteSite( siteFolder, deleteFiles ) );
}

async function deleteSite( siteFolder: string, deleteFiles: boolean ): Promise< void > {
try {
logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon…' ) );
await connectToDaemon();
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/commands/site/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import CliTable3 from 'cli-table3';
import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core';
import { getSiteUrl } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getLiveSiteOperation } from 'cli/lib/site-operations';
import { isSiteRunning } from 'cli/lib/site-utils';
import { getColumnWidths, getPrettyPath } from 'cli/lib/utils';
import { Logger, LoggerError } from 'cli/logger';
Expand Down Expand Up @@ -44,6 +45,10 @@ async function getSiteListData( sites: SiteData[] ): Promise< {

jsonEntries.push( {
...site,
// Overrides the stored value from `...site`: it can still name a
// process that has died, and both front ends decide which site
// actions to disable from what this reports.
operation: getLiveSiteOperation( site ),
url,
running,
} );
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/commands/site/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { __, sprintf } from '@wordpress/i18n';
import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getAiInstructionsPath } from 'cli/lib/dependency-management/paths';
import { withSiteOperation } from 'cli/lib/site-operations';
import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils';
import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration';
import { isServerRunning, startWordPressServer } from 'cli/lib/wordpress-server-manager';
Expand All @@ -17,6 +18,16 @@ export async function runCommand(
sitePath: string,
skipBrowser = false,
skipLogDetails = false
): Promise< void > {
return withSiteOperation( sitePath, 'start', () =>
startSite( sitePath, skipBrowser, skipLogDetails )
);
}

async function startSite(
sitePath: string,
skipBrowser: boolean,
skipLogDetails: boolean
): Promise< void > {
try {
logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon…' ) );
Expand Down
11 changes: 11 additions & 0 deletions apps/cli/commands/site/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
disconnectFromDaemon,
killDaemonAndChildren,
} from 'cli/lib/daemon-client';
import { withSiteOperation } from 'cli/lib/site-operations';
import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils';
import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks';
import {
Expand Down Expand Up @@ -53,6 +54,16 @@ export async function runCommand(
siteFolder: undefined
): Promise< void >;
export async function runCommand( target: Mode, siteFolder: string | undefined ): Promise< void > {
// Stopping everything is the quit path — it kills the daemon outright, so
// there is no per-site operation to take (and taking one for every site could
// block on an operation this is about to terminate anyway).
if ( target === Mode.STOP_SINGLE_SITE && siteFolder ) {
return withSiteOperation( siteFolder, 'stop', () => stopSites( target, siteFolder ) );
}
return stopSites( target, siteFolder );
}

async function stopSites( target: Mode, siteFolder: string | undefined ): Promise< void > {
try {
await connectToDaemon();

Expand Down
7 changes: 7 additions & 0 deletions apps/cli/commands/site/tests/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ vi.mock( 'cli/lib/cli-config/sites', async () => {
vi.mock( 'cli/lib/certificate-manager' );
vi.mock( 'cli/lib/hosts-file' );
vi.mock( 'cli/lib/daemon-client' );
// Run the command body directly: these suites cover the command, not the
// operation guard (lib/tests/site-operations.test.ts does that). Spreading the real module keeps
// any other export real rather than silently stubbing it.
vi.mock( 'cli/lib/site-operations', async ( importOriginal ) => ( {
...( await importOriginal< typeof import('cli/lib/site-operations') >() ),
withSiteOperation: ( _folder: string, _kind: string, fn: () => unknown ) => fn(),
} ) );
vi.mock( 'cli/lib/site-utils' );
vi.mock( 'cli/lib/snapshots' );
vi.mock( 'cli/lib/wordpress-server-manager' );
Expand Down
19 changes: 19 additions & 0 deletions apps/cli/commands/site/tests/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,25 @@ describe( 'CLI: studio site list', () => {
expect( disconnectFromDaemon ).toHaveBeenCalled();
} );

// Both front ends disable a site's actions on what this reports, so an
// entry left behind by a crashed process must not survive into the payload.
it( 'should omit an operation whose owning process is gone', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( {
...testCliConfig,
sites: [
{
...testCliConfig.sites[ 0 ],
operation: { pid: 0x7ffffffe, kind: 'delete' as const },
},
],
} );

await runCommand( 'json' );

const [ , json ] = mockReportKeyValuePair.mock.calls[ 0 ];
expect( JSON.parse( json )[ 0 ] ).not.toHaveProperty( 'operation' );
} );

it( 'should handle no sites found', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( emptyCliConfig );

Expand Down
7 changes: 7 additions & 0 deletions apps/cli/commands/site/tests/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ vi.mock( 'cli/lib/cli-config/sites', async () => ( {
updateSiteLatestCliPid: vi.fn(),
} ) );
vi.mock( 'cli/lib/daemon-client' );
// Run the command body directly: these suites cover the command, not the
// operation guard (lib/tests/site-operations.test.ts does that). Spreading the real module keeps
// any other export real rather than silently stubbing it.
vi.mock( 'cli/lib/site-operations', async ( importOriginal ) => ( {
...( await importOriginal< typeof import('cli/lib/site-operations') >() ),
withSiteOperation: ( _folder: string, _kind: string, fn: () => unknown ) => fn(),
} ) );
vi.mock( 'cli/lib/site-utils' );
vi.mock( 'cli/lib/wordpress-server-manager' );
vi.mock( 'cli/lib/sqlite-integration' );
Expand Down
7 changes: 7 additions & 0 deletions apps/cli/commands/site/tests/stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ vi.mock( 'cli/lib/cli-config/sites', async () => {
};
} );
vi.mock( 'cli/lib/daemon-client' );
// Run the command body directly: these suites cover the command, not the
// operation guard (lib/tests/site-operations.test.ts does that). Spreading the real module keeps
// any other export real rather than silently stubbing it.
vi.mock( 'cli/lib/site-operations', async ( importOriginal ) => ( {
...( await importOriginal< typeof import('cli/lib/site-operations') >() ),
withSiteOperation: ( _folder: string, _kind: string, fn: () => unknown ) => fn(),
} ) );
vi.mock( 'cli/lib/site-utils' );
vi.mock( 'cli/lib/wordpress-server-manager' );
vi.mock( 'cli/lib/tracks', async ( importActual ) => {
Expand Down
20 changes: 17 additions & 3 deletions apps/cli/lib/cli-config/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { siteDetailsSchema } from '@studio/common/lib/cli-events';
import { hideDirectoryOnWindows } from '@studio/common/lib/hide-dir-windows';
import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile';
import { siteOperationSchema } from '@studio/common/lib/site-operation';
import { getCliConfigPath, getConfigDirectory } from '@studio/common/lib/well-known-paths';
import { snapshotSchema } from '@studio/common/types/snapshot';
import { __ } from '@wordpress/i18n';
Expand Down Expand Up @@ -55,6 +56,10 @@ const siteSchema = siteDetailsSchema
// first-full-pull vs. delta. Durable on the site record.
importComplete: z.boolean().optional(),
status: siteStatusSchema.default( 'ready' ).optional(),
// The in-flight Studio operation holding this site. Unlike `status`, it's
// transient: once its owning process is gone it's reclaimed on the next
// acquire. See `cli/lib/site-operations`.
operation: siteOperationSchema.optional(),
} )
.loose();

Expand Down Expand Up @@ -166,18 +171,27 @@ export async function saveCliConfig( config: CliConfig ): Promise< void > {
}
}

const LOCKFILE_PATH = path.join( getConfigDirectory(), CLI_CONFIG_LOCKFILE_NAME );
// Resolved per call, not at module load: `getConfigDirectory()` reads
// DEV_CONFIG_DIR, so pinning it at import time would bake in whatever the
// environment looked like when this module was first pulled in — and would
// throw outright for any importer that loads before the home path resolves.
function getLockfilePath(): string {
return path.join( getConfigDirectory(), CLI_CONFIG_LOCKFILE_NAME );
}

export async function lockCliConfig(): Promise< void > {
// The lockfile lives inside the config directory. On a first run that directory may not exist
// yet (e.g. telemetry bumps fire before `setupServerFiles()` creates it), and `lockfile.lock`
// would reject with ENOENT instead of waiting. Ensure the directory exists before locking.
await ensureConfigDirectory();
await lockFileAsync( LOCKFILE_PATH, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } );
await lockFileAsync( getLockfilePath(), {
wait: LOCKFILE_WAIT_TIME,
stale: LOCKFILE_STALE_TIME,
} );
}

export async function unlockCliConfig(): Promise< void > {
await unlockFileAsync( LOCKFILE_PATH );
await unlockFileAsync( getLockfilePath() );
}

export async function updateCliConfigWithPartial(
Expand Down
Loading