Skip to content

Commit fd1d70e

Browse files
authored
Merge branch 'trunk' into fix-whats-new-native-php-truncation
2 parents 76fa50f + e7ce5c5 commit fd1d70e

20 files changed

Lines changed: 1112 additions & 214 deletions

File tree

‎apps/cli/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@earendil-works/pi-ai": "0.78.0",
2828
"@earendil-works/pi-coding-agent": "0.78.0",
2929
"@earendil-works/pi-tui": "0.78.0",
30+
"@formatjs/intl-localematcher": "^0.5.4",
3031
"@inquirer/prompts": "^8.5.2",
3132
"@modelcontextprotocol/sdk": "^1.27.1",
3233
"@php-wasm/node": "3.1.38",
@@ -64,7 +65,7 @@
6465
"build": "vite build --config ./vite.config.dev.ts",
6566
"build:prod": "vite build --config ./vite.config.prod.ts",
6667
"build:npm": "vite build --config ./vite.config.npm.ts",
67-
"install:bundle": "npm install --omit=dev --no-package-lock --no-progress --install-links --no-workspaces && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
68+
"install:bundle": "npm install --omit=dev --no-audit --no-fund --no-package-lock --no-progress --install-links --no-workspaces && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
6869
"package": "npm run install:bundle && npm run build:prod",
6970
"lint": "eslint .",
7071
"watch": "vite build --config ./vite.config.dev.ts --watch",

‎apps/cli/process-manager-daemon.ts‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import fs, { createWriteStream, WriteStream } from 'fs';
33
import net from 'net';
44
import path from 'path';
55
import readline from 'readline';
6-
import { SITE_RUNTIME_PLAYGROUND, type SiteRuntime } from '@studio/common/lib/site-runtime';
6+
import {
7+
SITE_RUNTIME_NATIVE_PHP,
8+
SITE_RUNTIME_PLAYGROUND,
9+
type SiteRuntime,
10+
} from '@studio/common/lib/site-runtime';
711
import semver from 'semver';
812
import {
913
PROCESS_MANAGER_LOGS_DIR,
@@ -29,6 +33,16 @@ const STOP_TIMEOUT_MS = 2_500;
2933
const STDERR_BUFFER_MAX_LINES = 100;
3034
const STDERR_BUFFER_MAX_BYTES = 16 * 1024;
3135

36+
// Weighted capacity limit for site processes. Playground (PHP WASM) sites use ~6x more memory
37+
// than native PHP sites (~720 MB vs ~120 MB), so they carry a heavier weight. The cap of 36
38+
// allows up to 36 native-PHP sites or 6 Playground sites (or a mix).
39+
const SITE_PROCESS_PREFIX = 'studio-site-';
40+
const CAPACITY_WEIGHTS: Record< SiteRuntime, number > = {
41+
[ SITE_RUNTIME_NATIVE_PHP ]: 1,
42+
[ SITE_RUNTIME_PLAYGROUND ]: 6,
43+
};
44+
const MAX_WEIGHTED_CAPACITY = 36;
45+
3246
type ManagedProcessBase = {
3347
pmId: number;
3448
name: string;
@@ -202,6 +216,16 @@ export class ProcessManagerDaemon {
202216
return undefined;
203217
}
204218

219+
private getWeightedCapacityUsage(): number {
220+
let usage = 0;
221+
for ( const proc of this.managedProcesses.values() ) {
222+
if ( proc.status === 'online' && proc.name.startsWith( SITE_PROCESS_PREFIX ) ) {
223+
usage += CAPACITY_WEIGHTS[ proc.runtime ];
224+
}
225+
}
226+
return usage;
227+
}
228+
205229
private async startProcess(
206230
processName: string,
207231
scriptPath: string,
@@ -214,6 +238,18 @@ export class ProcessManagerDaemon {
214238
return this.toProcessDescription( existing );
215239
}
216240

241+
if ( processName.startsWith( SITE_PROCESS_PREFIX ) ) {
242+
const weight = CAPACITY_WEIGHTS[ runtime ];
243+
const currentUsage = this.getWeightedCapacityUsage();
244+
if ( currentUsage + weight > MAX_WEIGHTED_CAPACITY ) {
245+
const errorMessage =
246+
runtime === SITE_RUNTIME_PLAYGROUND
247+
? `Cannot start site. The maximum number of running sites has been reached (${ currentUsage }/${ MAX_WEIGHTED_CAPACITY }). Sandbox sites count as ${ weight } units. Stop some running sites first.`
248+
: `Cannot start site. The maximum number of running sites has been reached (${ currentUsage }/${ MAX_WEIGHTED_CAPACITY }). Stop some running sites first.`;
249+
throw new Error( `CAPACITY_LIMIT_REACHED: ${ errorMessage }` );
250+
}
251+
}
252+
217253
const pmId = this.nextPmId++;
218254
const { stdoutLogPath, stderrLogPath } = getProcessLogPaths( processName );
219255
const stdoutStream = createWriteStream( stdoutLogPath, { flags: 'a' } );

‎apps/cli/tests/daemon.test.ts‎

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from 'fs';
33
import os from 'os';
44
import path from 'path';
55
import { PassThrough } from 'stream';
6-
import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime';
6+
import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime';
77
import { beforeEach, describe, expect, it, vi } from 'vitest';
88

99
const testProcessName = 'studio-site-process-manager-test';
@@ -297,6 +297,129 @@ describe( 'ProcessManagerDaemon', () => {
297297
}
298298
);
299299

300+
describe( 'weighted capacity limit', () => {
301+
type HandleRequestFn = ( request: unknown ) => Promise< {
302+
type: string;
303+
payload: { process?: { pmId: number; name: string; status: string; pid?: number } };
304+
} >;
305+
306+
async function startSiteProcess(
307+
handleRequest: HandleRequestFn,
308+
index: number,
309+
runtime: string = SITE_RUNTIME_NATIVE_PHP
310+
) {
311+
return handleRequest( {
312+
type: 'start-process',
313+
requestId: String( index ),
314+
processName: `studio-site-site-${ index }`,
315+
scriptPath: '/tmp/test-child.js',
316+
env: {},
317+
args: [],
318+
runtime,
319+
} );
320+
}
321+
322+
it( 'rejects a site process when the weighted capacity limit is exceeded', async () => {
323+
spawnMock.mockImplementation( () => new MockChildProcess() );
324+
const { ProcessManagerDaemon } = await import( '../process-manager-daemon' );
325+
326+
const daemon = new ProcessManagerDaemon();
327+
const handleRequest = (
328+
daemon as unknown as { handleRequest: HandleRequestFn }
329+
).handleRequest.bind( daemon );
330+
vi.spyOn(
331+
daemon as unknown as { broadcastEvent: ( event: unknown ) => Promise< void > },
332+
'broadcastEvent'
333+
).mockResolvedValue( undefined );
334+
335+
// Start 36 native-php sites (weight 1 each = 36 total, at the limit)
336+
for ( let i = 0; i < 36; i++ ) {
337+
await startSiteProcess( handleRequest, i );
338+
}
339+
340+
// The 37th native-php site should be rejected
341+
await expect( startSiteProcess( handleRequest, 37 ) ).rejects.toThrow(
342+
'CAPACITY_LIMIT_REACHED'
343+
);
344+
} );
345+
346+
it( 'counts playground sites with weight 6', async () => {
347+
spawnMock.mockImplementation( () => new MockChildProcess() );
348+
const { ProcessManagerDaemon } = await import( '../process-manager-daemon' );
349+
350+
const daemon = new ProcessManagerDaemon();
351+
const handleRequest = (
352+
daemon as unknown as { handleRequest: HandleRequestFn }
353+
).handleRequest.bind( daemon );
354+
vi.spyOn(
355+
daemon as unknown as { broadcastEvent: ( event: unknown ) => Promise< void > },
356+
'broadcastEvent'
357+
).mockResolvedValue( undefined );
358+
359+
// Start 6 playground sites (weight 6 each = 36 total, at the limit)
360+
for ( let i = 0; i < 6; i++ ) {
361+
await startSiteProcess( handleRequest, i, SITE_RUNTIME_PLAYGROUND );
362+
}
363+
364+
// The 7th playground site should be rejected
365+
await expect( startSiteProcess( handleRequest, 7, SITE_RUNTIME_PLAYGROUND ) ).rejects.toThrow(
366+
'CAPACITY_LIMIT_REACHED'
367+
);
368+
} );
369+
370+
it( 'does not count non-site processes toward capacity', async () => {
371+
spawnMock.mockImplementation( () => new MockChildProcess() );
372+
const { ProcessManagerDaemon } = await import( '../process-manager-daemon' );
373+
374+
const daemon = new ProcessManagerDaemon();
375+
const handleRequest = (
376+
daemon as unknown as { handleRequest: HandleRequestFn }
377+
).handleRequest.bind( daemon );
378+
vi.spyOn(
379+
daemon as unknown as { broadcastEvent: ( event: unknown ) => Promise< void > },
380+
'broadcastEvent'
381+
).mockResolvedValue( undefined );
382+
383+
// Start a non-site process (e.g. proxy)
384+
await handleRequest( {
385+
type: 'start-process',
386+
requestId: '0',
387+
processName: 'studio-proxy',
388+
scriptPath: '/tmp/proxy.js',
389+
env: {},
390+
args: [],
391+
} );
392+
393+
// Should still be able to start 36 native-php site processes
394+
for ( let i = 0; i < 36; i++ ) {
395+
await startSiteProcess( handleRequest, i );
396+
}
397+
} );
398+
399+
it( 'allows restarting an already-running process regardless of capacity', async () => {
400+
spawnMock.mockImplementation( () => new MockChildProcess() );
401+
const { ProcessManagerDaemon } = await import( '../process-manager-daemon' );
402+
403+
const daemon = new ProcessManagerDaemon();
404+
const handleRequest = (
405+
daemon as unknown as { handleRequest: HandleRequestFn }
406+
).handleRequest.bind( daemon );
407+
vi.spyOn(
408+
daemon as unknown as { broadcastEvent: ( event: unknown ) => Promise< void > },
409+
'broadcastEvent'
410+
).mockResolvedValue( undefined );
411+
412+
// Fill capacity
413+
for ( let i = 0; i < 36; i++ ) {
414+
await startSiteProcess( handleRequest, i );
415+
}
416+
417+
// Re-starting an existing process should succeed (early return, no capacity check)
418+
const result = await startSiteProcess( handleRequest, 0 );
419+
expect( result.payload.process?.status ).toBe( 'online' );
420+
} );
421+
} );
422+
300423
it( 'taskkills the wrapper and reported subprocess trees on Windows', async () => {
301424
const child = new MockChildProcess();
302425
spawnMock.mockReturnValue( child );

‎apps/studio/package.json‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,14 @@
2121
"make:macos-arm64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && tsx ../../scripts/build-ui-renderer.ts && SKIP_DMG=true FILE_ARCHITECTURE=arm64 electron-forge make . --arch=arm64 --platform=darwin",
2222
"make:linux-x64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && tsx ../../scripts/build-ui-renderer.ts && electron-forge make . --arch=x64 --platform=linux",
2323
"make:linux-arm64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && tsx ../../scripts/build-ui-renderer.ts && electron-forge make . --arch=arm64 --platform=linux",
24-
"install:bundle": "npm install --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
24+
"install:bundle": "npm install --omit=dev --no-audit --no-fund --no-package-lock --no-progress --install-links --no-workspaces && node ../../scripts/apply-available-patches.mjs patches && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
2525
"lint": "eslint src e2e",
2626
"package": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && tsx ../../scripts/build-ui-renderer.ts && electron-forge package .",
2727
"publish": "electron-forge publish .",
2828
"start-wayland": "npm -w wp-studio run build && electron-forge start . -- --enable-features=UseOzonePlatform --ozone-platform=wayland",
2929
"typecheck": "tsc -p tsconfig.json --noEmit"
3030
},
3131
"dependencies": {
32-
"@formatjs/intl-locale": "^3.4.5",
3332
"@formatjs/intl-localematcher": "^0.5.4",
3433
"@sentry/electron": "^7.13.0",
3534
"@studio/common": "file:../../tools/common",

‎apps/studio/src/components/site-management-actions.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { syncOperationsSelectors } from 'src/stores/sync';
99

1010
export interface SiteManagementActionProps {
1111
onStop: ( id: string ) => Promise< void >;
12-
onStart: ( site: SiteDetails ) => Promise< void >;
12+
onStart: ( site: SiteDetails ) => Promise< void | { capacityLimitReached: boolean } >;
1313
selectedSite?: SiteDetails | null;
1414
loading: boolean;
1515
}

‎apps/studio/src/hooks/tests/use-site-details.test.tsx‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,81 @@ describe( 'useSiteDetails', () => {
328328
} )
329329
);
330330
} );
331+
332+
it( 'should show capacity limit error and return capacityLimitReached', async () => {
333+
const { showErrorMessageBox } = setupStartServerError(
334+
new Error( 'CAPACITY_LIMIT_REACHED' )
335+
);
336+
337+
const { result } = renderHook( () => useSiteDetails(), { wrapper } );
338+
339+
await waitFor( () => {
340+
expect( result.current.loadingSites ).toBe( false );
341+
} );
342+
343+
vi.mocked( getIpcApi().startServer ).mockClear();
344+
345+
let startResult: { capacityLimitReached: boolean } | undefined;
346+
await act( async () => {
347+
startResult = await result.current.startServer( mockSites[ 0 ] as SiteDetails );
348+
} );
349+
350+
expect( startResult?.capacityLimitReached ).toBe( true );
351+
expect( showErrorMessageBox ).toHaveBeenCalledWith(
352+
expect.objectContaining( {
353+
title: "Failed to start 'Site 1'",
354+
message: expect.stringContaining( 'maximum number of running sites' ),
355+
} )
356+
);
357+
} );
358+
} );
359+
360+
describe( 'autoStart shows single error on capacity limit', () => {
361+
it( 'should start all sites in parallel and show a single error when capacity limit is reached', async () => {
362+
const autoStartSites = [
363+
{ ...mockSites[ 0 ], autoStart: true },
364+
{ ...mockSites[ 1 ], autoStart: true },
365+
{ ...mockSites[ 2 ], autoStart: true },
366+
];
367+
368+
const startServer = vi
369+
.fn()
370+
.mockResolvedValueOnce( undefined )
371+
.mockRejectedValueOnce( new Error( 'CAPACITY_LIMIT_REACHED' ) )
372+
.mockResolvedValueOnce( undefined );
373+
374+
const showErrorMessageBox = vi.fn();
375+
const stopServer = vi.fn( () => Promise.resolve() );
376+
377+
vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( {
378+
getSiteDetails: vi.fn().mockResolvedValue( autoStartSites ),
379+
startServer,
380+
showErrorMessageBox,
381+
stopServer,
382+
getConnectedWpcomSites: vi.fn( () => Promise.resolve( [] ) ),
383+
} );
384+
385+
const { result } = renderHook( () => useSiteDetails(), { wrapper } );
386+
387+
await waitFor( () => {
388+
expect( result.current.loadingSites ).toBe( false );
389+
} );
390+
391+
// Wait for parallel autoStart to complete
392+
await waitFor( () => {
393+
// All sites are attempted in parallel
394+
expect( startServer ).toHaveBeenCalledWith( 'site-1' );
395+
expect( startServer ).toHaveBeenCalledWith( 'site-2' );
396+
expect( startServer ).toHaveBeenCalledWith( 'site-3' );
397+
// A single consolidated error modal is shown
398+
expect( showErrorMessageBox ).toHaveBeenCalledTimes( 1 );
399+
expect( showErrorMessageBox ).toHaveBeenCalledWith(
400+
expect.objectContaining( {
401+
message: expect.stringContaining( 'maximum number of running sites' ),
402+
} )
403+
);
404+
} );
405+
} );
331406
} );
332407

333408
describe( 'site deletion selection behavior', () => {

0 commit comments

Comments
 (0)