Skip to content
Next Next commit
Ensure lock files get detected after 10 minutes
  • Loading branch information
Kateryna Kodonenko
Kateryna Kodonenko committed Jun 24, 2026
commit 63ad173cabffe0cd140bf811d8d2e32b02db99b4
26 changes: 26 additions & 0 deletions apps/studio/src/hooks/use-site-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,32 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) {
showOpenLogs: false,
} );
}
} else if (
error instanceof Error &&
error.message.includes( 'MAINTENANCE_FILE_STALE' )
) {
const filePath = error.message.split( ':' ).slice( 1 ).join( ':' );
getIpcApi().showErrorMessageBox( {
title: sprintf( __( "Failed to start '%s'" ), siteName ),
message:
__(
'A previous update left a .maintenance lock file behind that is preventing the site from starting. The update appears to have finished or failed. You can safely delete this file to start the site:'
) +
'\n\n' +
filePath,
showOpenLogs: false,
} );
} else if (
error instanceof Error &&
error.message.includes( 'MAINTENANCE_FILE_FRESH' )
) {
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
18 changes: 18 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
} from '@studio/common/lib/blueprint-bundle';
import { validateBlueprintData } from '@studio/common/lib/blueprint-validation';
import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error';
import { checkMaintenanceFile } from '@studio/common/lib/maintenance-file';
import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites';
import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore';
import { extractZip } from '@studio/common/lib/extract-zip';
Expand Down Expand Up @@ -1030,6 +1031,23 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom
throw new Error( 'CAPACITY_LIMIT_REACHED' );
}

// Check if a .maintenance file is blocking the site from starting.
// WordPress creates this file during core/plugin/theme updates.
const maintenanceCheck = checkMaintenanceFile( server.details.path );
if ( maintenanceCheck.exists ) {
if ( maintenanceCheck.isStale ) {
throw new Error(
`MAINTENANCE_FILE_STALE:${ maintenanceCheck.filePath }`
);
}
const minutesLeft = maintenanceCheck.expiresAt
? Math.ceil( ( maintenanceCheck.expiresAt.getTime() - Date.now() ) / 60000 )
: 10;
throw new Error(
`MAINTENANCE_FILE_FRESH:${ minutesLeft }`
);
}

const contexts: Record< string, Record< string, unknown > > = {
server: {
running: server.details.running,
Expand Down
73 changes: 73 additions & 0 deletions tools/common/lib/maintenance-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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;
filePath: string;
upgradingTimestamp: number;
isStale: boolean;
expiresAt: Date | null;
}

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>; ?>
*/
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 {
return {
exists: true,
filePath,
upgradingTimestamp: 0,
isStale: true,
expiresAt: null,
};
}

const match = content.match( /\$upgrading\s*=\s*(\d+)/ );
if ( ! match ) {
return {
exists: true,
filePath,
upgradingTimestamp: 0,
isStale: true,
expiresAt: null,
};
}

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

return {
exists: true,
filePath,
upgradingTimestamp,
isStale,
expiresAt: isStale
? null
: new Date( ( upgradingTimestamp + WP_MAINTENANCE_TIMEOUT_SECONDS ) * 1000 ),
};
}
91 changes: 91 additions & 0 deletions tools/common/lib/tests/maintenance-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import fs from 'fs';
import path from 'path';
import { checkMaintenanceFile } from '../maintenance-file';

describe( 'checkMaintenanceFile', () => {
const sitePath = '/tmp/test-site';
const maintenancePath = path.join( sitePath, '.maintenance' );

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 fresh maintenance info 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.exists ).toBe( true );
if ( result.exists ) {
expect( result.filePath ).toBe( maintenancePath );
expect( result.upgradingTimestamp ).toBe( nowSeconds );
expect( result.isStale ).toBe( false );
expect( result.expiresAt ).toBeInstanceOf( Date );
}
} );

it( 'returns stale maintenance info 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.exists ).toBe( true );
if ( result.exists ) {
expect( result.upgradingTimestamp ).toBe( oldTimestamp );
expect( result.isStale ).toBe( true );
expect( result.expiresAt ).toBeNull();
}
} );

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.exists ).toBe( true );
if ( result.exists ) {
expect( result.isStale ).toBe( true );
expect( result.upgradingTimestamp ).toBe( 0 );
expect( result.expiresAt ).toBeNull();
}
} );

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

const result = checkMaintenanceFile( sitePath );

expect( result.exists ).toBe( true );
if ( result.exists ) {
expect( result.isStale ).toBe( 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.exists ).toBe( true );
if ( result.exists ) {
expect( result.isStale ).toBe( true );
}
} );
} );