diff --git a/apps/cli/lib/native-php/tests/worker-ports.test.ts b/apps/cli/lib/native-php/tests/worker-ports.test.ts new file mode 100644 index 0000000000..c30fd14ef2 --- /dev/null +++ b/apps/cli/lib/native-php/tests/worker-ports.test.ts @@ -0,0 +1,40 @@ +/** + * @vitest-environment node + */ +import { expect, it, vi } from 'vitest'; +import { getAvailablePorts } from '../worker-ports'; + +// An OS that always hands out the lowest free port, so a released port comes straight back. +// Linux does this at random (about 1 in 1,000 four-port pools on a real kernel). +const { boundPorts } = vi.hoisted( () => ( { boundPorts: new Set< number >() } ) ); + +vi.mock( 'node:net', () => { + class FakeServer { + port = 0; + once() {} + unref() { + return this; + } + listen( _port: number, _host: string, onListening: () => void ) { + for ( this.port = 50_000; boundPorts.has( this.port ); this.port++ ); + boundPorts.add( this.port ); + setImmediate( onListening ); + } + address() { + return { port: this.port }; + } + close( onClose: () => void ) { + boundPorts.delete( this.port ); + setImmediate( onClose ); + } + } + const net = { createServer: () => new FakeServer() }; + return { default: net, ...net }; +} ); + +it( 'returns distinct ports and releases them all for the workers to bind', async () => { + const ports = await getAvailablePorts( 4 ); + + expect( new Set( ports ).size ).toBe( 4 ); + expect( boundPorts.size ).toBe( 0 ); +} ); diff --git a/apps/cli/lib/native-php/worker-ports.ts b/apps/cli/lib/native-php/worker-ports.ts new file mode 100644 index 0000000000..5caa45b6fe --- /dev/null +++ b/apps/cli/lib/native-php/worker-ports.ts @@ -0,0 +1,22 @@ +import net from 'node:net'; + +// Returns `count` distinct free ports for the PHP workers. Every probe stays bound until all the +// ports are chosen, so the OS can't hand a port that was just released to the next probe. +export async function getAvailablePorts( count: number ): Promise< number[] > { + const servers: net.Server[] = []; + try { + for ( let index = 0; index < count; index++ ) { + const server = net.createServer().unref(); + servers.push( server ); + await new Promise< void >( ( resolve, reject ) => { + server.once( 'error', reject ); + server.listen( 0, '127.0.0.1', resolve ); + } ); + } + return servers.map( ( server ) => ( server.address() as net.AddressInfo ).port ); + } finally { + await Promise.all( + servers.map( ( server ) => new Promise( ( resolve ) => server.close( resolve ) ) ) + ); + } +} diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index 5f9d579ebf..9b537fffb9 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -8,7 +8,6 @@ import fs from 'node:fs'; import http from 'node:http'; -import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { writeStudioMuPlugins } from '@studio/common/lib/mu-plugins'; @@ -45,6 +44,7 @@ import { installWordPress, writeSiteUrlPrependFile, } from './lib/native-php/site-setup'; +import { getAvailablePorts } from './lib/native-php/worker-ports'; import { getSiteEmailDirectory } from './lib/site-email'; import { SymlinkWatcher, collectSymlinkAllowlistEntries } from './lib/symlinks'; import type { ChildProcess } from 'node:child_process'; @@ -171,23 +171,6 @@ function pickPhpWorker( req: http.IncomingMessage ): { index: number; port: numb return { index: bestIndex, port: phpWorkerPorts[ bestIndex ] }; } -async function getAvailablePort(): Promise< number > { - return await new Promise< number >( ( resolve, reject ) => { - const server = net.createServer(); - server.unref(); - server.once( 'error', reject ); - server.listen( 0, '127.0.0.1', () => { - const address = server.address(); - if ( ! address || typeof address === 'string' ) { - server.close( () => reject( new Error( 'Could not allocate a PHP worker port' ) ) ); - return; - } - const port = address.port; - server.close( () => resolve( port ) ); - } ); - } ); -} - async function waitForServerReady( url: string, signal?: AbortSignal ): Promise< void > { const pollIntervalMs = 50; const timeoutMs = 30_000; @@ -620,10 +603,7 @@ async function doStartServer( } catch ( error ) { errorToConsole( `Failed to create the email folder ${ emailDirectory }:`, error ); } - const workerPorts: number[] = []; - for ( let index = 0; index < NATIVE_PHP_WORKER_POOL_SIZE; index++ ) { - workerPorts.push( await getAvailablePort() ); - } + const workerPorts = await getAvailablePorts( NATIVE_PHP_WORKER_POOL_SIZE ); phpWorkerPorts = workerPorts; phpWorkerRequestTracker = new PhpWorkerRequestTracker( workerPorts.length );