Skip to content
10 changes: 10 additions & 0 deletions apps/cli/commands/site/start.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { updateManagedInstructionFiles } from '@studio/common/lib/agent-skills';
import { checkMaintenanceFile } from '@studio/common/lib/maintenance-file';
import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __, sprintf } from '@wordpress/i18n';
import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites';
Expand Down Expand Up @@ -79,6 +80,15 @@ export async function runCommand(
);
}

const maintenanceCheck = checkMaintenanceFile( sitePath );
if ( maintenanceCheck.exists && ! maintenanceCheck.isStale ) {
throw new LoggerError(
__(
'This site is in maintenance mode. WordPress is currently performing an update. The maintenance lock should expire automatically within 10 minutes. Please wait and try again.'
)
);
}

logger.reportStart( LoggerAction.START_SITE, __( 'Starting WordPress server…' ) );
try {
const processDesc = await startWordPressServer( site, logger );
Expand Down
8 changes: 8 additions & 0 deletions apps/studio/src/hooks/use-site-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,14 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) {
showOpenLogs: false,
} );
}
} else if ( error instanceof Error && error.message.includes( 'MAINTENANCE_MODE' ) ) {
getIpcApi().showErrorMessageBox( {
title: sprintf( __( "'%s' is in maintenance mode" ), siteName ),
message: __(
'WordPress is currently performing an update. The maintenance lock should expire automatically within 10 minutes. Please wait for the update to finish and try starting the site again.'
),
showOpenLogs: false,
} );
} else {
const errorToShow = simplifyErrorForDisplay( error );
getIpcApi().showErrorMessageBox( {
Expand Down
6 changes: 6 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { generateNumberedName, generateSiteName } from '@studio/common/lib/gener
import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version';
import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import { isMultisite } from '@studio/common/lib/is-multisite';
import { checkMaintenanceFile } from '@studio/common/lib/maintenance-file';
import { getLocalMediaMimeType } from '@studio/common/lib/media-mime';
import { getAuthenticationUrl } from '@studio/common/lib/oauth';
import { decodePassword, encodePassword } from '@studio/common/lib/passwords';
Expand Down Expand Up @@ -936,6 +937,11 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom
return;
}

const maintenanceCheck = checkMaintenanceFile( server.details.path );
if ( maintenanceCheck.exists && ! maintenanceCheck.isStale ) {
throw new Error( 'MAINTENANCE_MODE' );
}

try {
await server.start();
} catch ( error ) {
Expand Down
57 changes: 57 additions & 0 deletions packages/common/lib/maintenance-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import fs from 'fs';
import path from 'path';

/**
* WordPress maintenance mode timeout (600 seconds = 10 minutes).
* Matches WP_INSTALLING_TIMEOUT in wp-includes/load.php.
*/
const WP_MAINTENANCE_TIMEOUT_SECONDS = 600;

export interface MaintenanceFileInfo {
exists: true;
isStale: boolean;
}

export interface NoMaintenanceFile {
exists: false;
}

export type MaintenanceFileCheck = MaintenanceFileInfo | NoMaintenanceFile;

/**
* Check for a WordPress .maintenance file and parse its $upgrading timestamp.
* WordPress creates this file during core/plugin/theme updates with the format:
* <?php $upgrading = <unix_timestamp>; ?>
*
* WordPress itself ignores the file after 10 minutes (WP_INSTALLING_TIMEOUT),
* so only fresh locks actually block requests.
*/
export function checkMaintenanceFile( sitePath: string ): MaintenanceFileCheck {
const filePath = path.join( sitePath, '.maintenance' );

if ( ! fs.existsSync( filePath ) ) {
return { exists: false };
}

let content: string;
try {
content = fs.readFileSync( filePath, 'utf-8' );
} catch {
// Unreadable file — treat as stale (won't block WordPress either).
return { exists: true, isStale: true };
}

const match = content.match( /\$upgrading\s*=\s*(\d+)/ );
if ( ! match ) {
// Malformed file — treat as stale.
return { exists: true, isStale: true };
}

const upgradingTimestamp = parseInt( match[ 1 ], 10 );
const ageSeconds = Date.now() / 1000 - upgradingTimestamp;

return {
exists: true,
isStale: ageSeconds > WP_MAINTENANCE_TIMEOUT_SECONDS,
};
}
67 changes: 67 additions & 0 deletions packages/common/lib/tests/maintenance-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import fs from 'fs';
import { checkMaintenanceFile } from '../maintenance-file';

describe( 'checkMaintenanceFile', () => {
const sitePath = '/tmp/test-site';

beforeEach( () => {
vi.restoreAllMocks();
} );

it( 'returns exists: false when no .maintenance file exists', () => {
vi.spyOn( fs, 'existsSync' ).mockReturnValue( false );

const result = checkMaintenanceFile( sitePath );

expect( result ).toEqual( { exists: false } );
} );

it( 'returns isStale: false for a recent timestamp', () => {
const nowSeconds = Math.floor( Date.now() / 1000 );
vi.spyOn( fs, 'existsSync' ).mockReturnValue( true );
vi.spyOn( fs, 'readFileSync' ).mockReturnValue( `<?php $upgrading = ${ nowSeconds }; ?>` );

const result = checkMaintenanceFile( sitePath );

expect( result ).toEqual( { exists: true, isStale: false } );
} );

it( 'returns isStale: true for an old timestamp', () => {
const oldTimestamp = Math.floor( Date.now() / 1000 ) - 700;
vi.spyOn( fs, 'existsSync' ).mockReturnValue( true );
vi.spyOn( fs, 'readFileSync' ).mockReturnValue( `<?php $upgrading = ${ oldTimestamp }; ?>` );

const result = checkMaintenanceFile( sitePath );

expect( result ).toEqual( { exists: true, isStale: true } );
} );

it( 'treats a malformed file as stale', () => {
vi.spyOn( fs, 'existsSync' ).mockReturnValue( true );
vi.spyOn( fs, 'readFileSync' ).mockReturnValue( '<?php // broken file' );

const result = checkMaintenanceFile( sitePath );

expect( result ).toEqual( { exists: true, isStale: true } );
} );

it( 'treats an empty file as stale', () => {
vi.spyOn( fs, 'existsSync' ).mockReturnValue( true );
vi.spyOn( fs, 'readFileSync' ).mockReturnValue( '' );

const result = checkMaintenanceFile( sitePath );

expect( result ).toEqual( { exists: true, isStale: true } );
} );

it( 'treats an unreadable file as stale', () => {
vi.spyOn( fs, 'existsSync' ).mockReturnValue( true );
vi.spyOn( fs, 'readFileSync' ).mockImplementation( () => {
throw new Error( 'EACCES: permission denied' );
} );

const result = checkMaintenanceFile( sitePath );

expect( result ).toEqual( { exists: true, isStale: true } );
} );
} );
Loading