@@ -10,24 +10,18 @@ export type PHPFactory = (options: PHPFactoryOptions) => Promise<PHP>;
1010
1111export interface ProcessManagerOptions {
1212 /**
13- * The maximum number of PHP instances that can exist at
13+ * The maximum number of PHP instances that can be in use at
1414 * the same time.
1515 */
1616 maxPhpInstances ?: number ;
1717 /**
1818 * The number of milliseconds to wait for a PHP instance when
19- * we have reached the maximum number of PHP instances and
20- * cannot spawn a new one. If the timeout is reached, we assume
21- * all the PHP instances are deadlocked and a throw MaxPhpInstancesError.
19+ * all instances are busy. If the timeout is reached, we assume
20+ * all the PHP instances are deadlocked and throw MaxPhpInstancesError.
2221 *
2322 * Default: 30000
2423 */
2524 timeout ?: number ;
26- /**
27- * The primary PHP instance that's never killed. This instance
28- * contains the reference filesystem used by all other PHP instances.
29- */
30- primaryPhp ?: PHP ;
3125 /**
3226 * A factory function used for spawning new PHP instances.
3327 */
@@ -44,208 +38,117 @@ export class MaxPhpInstancesError extends Error {
4438}
4539
4640/**
47- * A PHP Process manager.
48- *
49- * Maintains:
50- * * A single "primary" PHP instance that's never killed – it contains the
51- * reference filesystem used by all other PHP instances.
52- * * A pool of disposable PHP instances that are spawned to handle a single
53- * request and reaped immediately after.
41+ * A PHP Process manager that maintains a pool of reusable PHP instances.
5442 *
55- * When a new request comes in, PHPProcessManager yields the idle instance to
56- * handle it, and immediately starts initializing a new idle instance. In other
57- * words, for n concurrent requests, there are at most n+1 PHP instances
58- * running at the same time.
43+ * Instances are spawned on demand up to `maxPhpInstances` and reused across
44+ * requests. The first instance spawned is the "primary" instance which
45+ * contains the reference filesystem used by all other instances.
5946 *
60- * A slight nuance is that the first idle instance is not initialized until the
61- * first concurrent request comes in. This is because many use-cases won't
62- * involve parallel requests and, for those, we can avoid eagerly spinning up a
63- * second PHP instance.
64- *
65- * This strategy is inspired by Cowboy, an Erlang HTTP server. Handling a
66- * single extra request can happen immediately, while handling multiple extra
67- * requests requires extra time to spin up a few PHP instances. This is a more
68- * resource-friendly tradeoff than keeping 5 idle instances at all times.
47+ * The semaphore controls how many requests can be processed concurrently.
48+ * When all instances are busy, new requests wait in a queue until an
49+ * instance becomes available or the timeout is reached.
6950 */
7051export class PHPProcessManager implements PHPInstanceManager {
71- private primaryPhp ?: PHP ;
72- private primaryPhpPromise ?: Promise < AcquiredPHP > ;
73- private primaryIdle = true ;
74- private nextInstance : Promise < AcquiredPHP > | null = null ;
75- /**
76- * All spawned PHP instances, including the primary PHP instance.
77- * Used for bookkeeping and reaping all instances on dispose.
78- */
79- private allInstances : Promise < AcquiredPHP > [ ] = [ ] ;
52+ /** All PHP instances that have been spawned. */
53+ private instances : PHP [ ] = [ ] ;
54+ /** Instances that are currently idle and available for use. */
55+ private idleInstances : PHP [ ] = [ ] ;
8056 private phpFactory ?: PHPFactory ;
8157 private maxPhpInstances : number ;
8258 private semaphore : Semaphore ;
59+ /** Promise guard to prevent spawning multiple primary instances. */
60+ private primaryPhpPromise ?: Promise < PHP > ;
8361
8462 constructor ( options ?: ProcessManagerOptions ) {
8563 this . maxPhpInstances = options ?. maxPhpInstances ?? 2 ;
8664 this . phpFactory = options ?. phpFactory ;
87- this . primaryPhp = options ?. primaryPhp ;
8865 this . semaphore = new Semaphore ( {
8966 concurrency : this . maxPhpInstances ,
90- /**
91- * Wait up to 5 seconds for resources to become available
92- * before assuming that all the PHP instances are deadlocked.
93- */
9467 timeout : options ?. timeout || 30000 ,
9568 } ) ;
9669 }
9770
9871 /**
99- * Get the primary PHP instance.
100- *
101- * If the primary PHP instance is not set, it will be spawned
102- * using the provided phpFactory.
103- *
104- * @throws {Error } when called twice before the first call is resolved.
72+ * Get the primary PHP instance (the first one spawned).
73+ * If no instance exists yet, one will be spawned.
10574 */
106- async getPrimaryPhp ( ) {
107- if ( ! this . phpFactory && ! this . primaryPhp ) {
108- throw new Error (
109- 'phpFactory or primaryPhp must be set before calling getPrimaryPhp().'
110- ) ;
111- } else if ( ! this . primaryPhp ) {
75+ async getPrimaryPhp ( ) : Promise < PHP > {
76+ if ( this . instances . length === 0 ) {
11277 if ( ! this . primaryPhpPromise ) {
113- this . primaryPhpPromise = this . spawn ( { isPrimary : true } ) ;
78+ this . primaryPhpPromise = this . spawnInstance ( true ) ;
79+ this . primaryPhpPromise . finally ( ( ) => {
80+ this . primaryPhpPromise = undefined ;
81+ } ) ;
11482 }
115- this . primaryPhp = ( await this . primaryPhpPromise ) . php ;
116- this . primaryPhpPromise = undefined ;
83+ await this . primaryPhpPromise ;
11784 }
118- return this . primaryPhp ! ;
85+ return this . instances [ 0 ] ;
11986 }
12087
12188 /**
122- * Get a PHP instance.
89+ * Acquire a PHP instance for processing a request .
12390 *
124- * It could be either the primary PHP instance, an idle disposable PHP
125- * instance, or a newly spawned PHP instance – depending on the resource
126- * availability .
91+ * Returns an idle instance from the pool, or spawns a new one if
92+ * the pool isn't at capacity. If all instances are busy, waits
93+ * until one becomes available .
12794 *
128- * @throws {MaxPhpInstancesError } when the maximum number of PHP instances is reached
129- * and the waiting timeout is exceeded .
95+ * @throws {MaxPhpInstancesError } when the timeout is reached waiting
96+ * for an available instance .
13097 */
13198 async acquirePHPInstance ( ) : Promise < AcquiredPHP > {
132- /**
133- * First and foremost, make sure we have the primary PHP instance in place.
134- * We may not actually acquire it. We just need it to exist.
135- *
136- * @TODO : Re-evaluate why we need it to exist. Should spawn() be just more
137- * lenient with its "another primary instance already started spawning"
138- * check?
139- */
140- if ( ! this . primaryPhp ) {
141- await this . getPrimaryPhp ( ) ;
142- }
143-
144- if ( this . primaryIdle ) {
145- this . primaryIdle = false ;
146- return {
147- php : await this . getPrimaryPhp ( ) ,
148- reap : ( ) => {
149- this . primaryIdle = true ;
150- } ,
151- } ;
99+ let releaseSemaphore : ( ) => void ;
100+ try {
101+ releaseSemaphore = await this . semaphore . acquire ( ) ;
102+ } catch ( error ) {
103+ if ( error instanceof AcquireTimeoutError ) {
104+ throw new MaxPhpInstancesError ( this . maxPhpInstances ) ;
105+ }
106+ throw error ;
152107 }
153108
154- /**
155- * nextInstance is null:
156- *
157- * * Before the first concurrent getInstance() call
158- * * When the last getInstance() call did not have enough
159- * budget left to optimistically start spawning the next
160- * instance.
161- */
162- const acquiredPHP =
163- this . nextInstance || this . spawn ( { isPrimary : false } ) ;
109+ const php = await this . getOrSpawnInstance ( ) ;
164110
165- /**
166- * Start spawning the next instance if there's still room. We can't
167- * just always spawn the next instance because spawn() can fail
168- * asynchronously and then we'll get an unhandled promise rejection.
169- */
170- if ( this . semaphore . remaining > 0 ) {
171- this . nextInstance = this . spawn ( { isPrimary : false } ) ;
172- } else {
173- this . nextInstance = null ;
174- }
175- return await acquiredPHP ;
111+ return {
112+ php,
113+ reap : ( ) => {
114+ this . idleInstances . push ( php ) ;
115+ releaseSemaphore ( ) ;
116+ } ,
117+ } ;
176118 }
177119
178120 /**
179- * Initiated spawning of a new PHP instance.
180- * This function is synchronous on purpose – it needs to synchronously
181- * add the spawn promise to the allInstances array without waiting
182- * for PHP to spawn.
121+ * Get an idle instance or spawn a new one if under capacity.
183122 */
184- private spawn ( factoryArgs : PHPFactoryOptions ) : Promise < AcquiredPHP > {
185- if ( factoryArgs . isPrimary && this . allInstances . length > 0 ) {
186- throw new Error (
187- 'Requested spawning a primary PHP instance when another primary instance already started spawning.'
188- ) ;
123+ private async getOrSpawnInstance ( ) : Promise < PHP > {
124+ if ( this . idleInstances . length > 0 ) {
125+ return this . idleInstances . pop ( ) ! ;
189126 }
190- const spawned = this . doSpawn ( factoryArgs ) ;
191- this . allInstances . push ( spawned ) ;
192- const pop = ( ) => {
193- this . allInstances = this . allInstances . filter (
194- ( instance ) => instance !== spawned
195- ) ;
196- } ;
197- return spawned
198- . catch ( ( rejection ) => {
199- pop ( ) ;
200- throw rejection ;
201- } )
202- . then ( ( result ) => ( {
203- ...result ,
204- reap : ( ) => {
205- pop ( ) ;
206- result . reap ( ) ;
207- } ,
208- } ) ) ;
127+ if ( this . instances . length === 0 ) {
128+ return await this . getPrimaryPhp ( ) ;
129+ }
130+ return await this . spawnInstance ( false ) ;
209131 }
210132
211133 /**
212- * Actually acquires the lock and spawns a new PHP instance.
134+ * Spawn a new PHP instance.
213135 */
214- private async doSpawn (
215- factoryArgs : PHPFactoryOptions
216- ) : Promise < AcquiredPHP > {
217- let release : ( ) => void ;
218- try {
219- release = await this . semaphore . acquire ( ) ;
220- } catch ( error ) {
221- if ( error instanceof AcquireTimeoutError ) {
222- throw new MaxPhpInstancesError ( this . maxPhpInstances ) ;
223- }
224- throw error ;
225- }
226- try {
227- const php = await this . phpFactory ! ( factoryArgs ) ;
228- return {
229- php,
230- reap ( ) {
231- php . exit ( ) ;
232- release ( ) ;
233- } ,
234- } ;
235- } catch ( e ) {
236- release ( ) ;
237- throw e ;
136+ private async spawnInstance ( isPrimary : boolean ) : Promise < PHP > {
137+ if ( ! this . phpFactory ) {
138+ throw new Error (
139+ 'phpFactory must be set before spawning instances.'
140+ ) ;
238141 }
142+ const php = await this . phpFactory ( { isPrimary } ) ;
143+ this . instances . push ( php ) ;
144+ return php ;
239145 }
240146
241147 async [ Symbol . asyncDispose ] ( ) {
242- if ( this . primaryPhp ) {
243- this . primaryPhp . exit ( ) ;
148+ for ( const php of this . instances ) {
149+ php . exit ( ) ;
244150 }
245- await Promise . all (
246- this . allInstances . map ( ( instance ) =>
247- instance . then ( ( { reap } ) => reap ( ) )
248- )
249- ) ;
151+ this . instances = [ ] ;
152+ this . idleInstances = [ ] ;
250153 }
251154}
0 commit comments